1
0
Fork 0

feat(map): political zone types, road label tri-state, authoring snapping lib

- map-zones type gains territory and country border (informational political
  boundaries that never gate placement, default fills amber 0.1 / violet 0.06)
- map-roads labelVisible is tri-state: unset means auto, where paved roads
  always label and dirt/trail only label at 4 m/px or finer (roadLabelShown)
- migration clears the old implicit label default on non-paved roads
- new pure snapping lib: findSnap (vertices own their neighborhood, edges must
  be twice as close), closestPointOnSegment, segmentMidpoints
- tests for label angles, tri-state rule, snapping, political zones
This commit is contained in:
Jason Fraley 2026-09-17 16:27:55 -04:00
parent 9ca85fce9b
commit cc0a7facd0
13 changed files with 649 additions and 164 deletions

View file

@ -198,13 +198,13 @@ Labor/staffing simulation for structures. Collections in `src/collections/game/`
Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`.
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible, `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land, fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible (tri-state: unset = auto, paved labels always, dirt/trail only at close zoom via `roadLabelShown` in style.ts, 4 m/px threshold), `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land/territory/border (territory/border are informational political boundaries that never gate placement; default fills amber 0.1 / violet 0.06), fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
- **Coordinates**: real meters, per-map Cartesian grid, origin top-left, y runs south (Arma convention). JSON geometry for map features; `game-structures.coordinates` keeps its PostGIS point but stores meters (`validate: () => true` overrides Payload's geographic lng/lat validation; meters are not degrees).
- **Routing**: `src/lib/map/routing.ts` (pure) builds a graph from road polylines (1 m vertex epsilon; shared vertex = junction), Dijkstra weighted by `length / speedMultiplier`; origin/destination snap to the nearest vertex within 1000 m; no connected route refuses ground shipments with a clear error. Air/sea (`vehicles.transportMode`) go straight-line. Distance = geometric route length in meters; `routePath` JSON snapshot on shipments (auto-return reverses it). Speed math converts meters to km once in `calculateTransitTime`; fuel stays `meters x rate`.
- **Placement**: `src/lib/map/placement.ts` (pure: terrain point-in-polygon, resourcesInRange vs multi-resource nodes OR resource-bearing zones at the site, structuresInRange) + `placeStructure` action (`src/app/(frontend)/logistics/map/actions.ts`, hasLogisticsQualification gate, emits `structure:placed`). Map UI placement mode for logistics-qualified users (click to place, dialog with blueprint/name/faction, client pre-validation + server enforcement). Water zones block land blueprints; water blueprints need a water zone; no zones = everything allowed.
- **Construction**: `src/lib/map/construction.ts`. Construction runs on timestamps: `maybeStartConstruction(payload, siteId, now)` is called at delivery time (shipment `completeShipment`, the structures `addResource`/`transferResource` actions via `addResourceInternal`, and `placeStructure` for materialless blueprints) and, when every blueprint material is stored, consumes them via `consumeStorage`, sets `constructionStartedAt`/`constructionCompletesAt` from the blueprint's `constructionDurationMinutes` (minutes, replacing the old tick-based `constructionTime`), and flips `awaiting_materials` to `building`. The game tick's `processConstructionTick` is a sweeper only: building sites past `constructionCompletesAt` flip to `complete` + `structure:construction-complete` (idempotent). Default constructionStatus on game-structures is `complete` (backward compat). ConstructionPanel on the structure detail page shows required vs delivered + a timestamp-derived progress bar; map markers interpolate progress from the timestamps.
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`).
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts); `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`, `roadLabelShown`, `labelScreenAngle`; roads under 600 screen px render one straight midpoint label instead of curved text, since glyphs fold on tiny wiggly paths).
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts). Drafts get vertex editing: draggable square handles per draft vertex (drag-end snaps), right-click deletes (min 2 road / 3 zone points), cyan midpoint handles insert a vertex; clicks and drags snap within 12 screen px to feature vertices and zone/road edges (closing segments included; vertices beat edges unless the edge is 2x closer) via pure `src/lib/map/snapping.ts` (`findSnap`, `closestPointOnSegment`, `segmentMidpoints`; `AuthoringSnapLayer` + `DraftHandles` in MapClient). `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
- **GameRules**: `proximityThreshold` now means meters (default 1000; migration backfilled rows still at the old default 10). Both call sites (shipments/actions.ts, structures/actions.ts transferResource) use meters.
- **Tests**: `tests/int/map-system.int.spec.ts` (routing graph/snap/refusals, cross-map refusal, ground route + path snapshot, air straight-line, proximity, terrain/range validation, placeStructure happy/water/range cases, construction tick lifecycle, permission gating, label/styling helpers + save-action styling validation). Gotcha: Payload applies select `filterOptions` server-side, so test fixtures must use `approvalStatus: "in_progress"`.

View file

@ -100,10 +100,14 @@ stays display-only in the placement dialog (no research system exists).
- **New road**: click vertices along the route, at least 2; set name,
description, surface (paved/dirt/trail), speed multiplier, and styling
(stroke color hex, width 1-10, dash style, name-label toggle); Save.
(stroke color hex, width 1-10, dash style, name-label select: Auto/Shown/
Hidden, where Auto labels paved roads always and dirt/trail roads only at
close zoom, 4 m/px or finer); Save.
- **New zone**: click at least 3 vertices; set name, description, type
(water/land), and styling (fill color, edge color, fill opacity 0-1, edge
width, name-label toggle); Save.
(water/land/territory/border), and styling (fill color, edge color, fill
opacity 0-1, edge width, name-label toggle); Save. Territory and Country
Border are informational political boundaries (they never gate placement;
only water does) and default to amber 0.1 / violet 0.06 fill.
- **New node**: click once to set the position; set name, description,
resource, richness; Save.
- Existing roads/zones/nodes show Edit + Delete buttons in their popup while
@ -112,6 +116,20 @@ stays display-only in the placement dialog (no research system exists).
the fields, then "Save changes" (requires `maps:update`). Renaming, retyping,
restyling, and description edits work the same way; the feature's map cannot
be changed.
- **Vertex editing**: while a road/zone/node draft is active, every draft
vertex renders as a draggable square handle: drag to move it (drag-end
snaps), right-click to delete it (roads keep 2+ points, zones 3+), and the
small cyan square at the middle of each segment inserts a vertex there when
clicked.
- **Snapping**: while authoring, clicks and dragged vertices snap to existing
geometry within 12 screen px: feature vertices (roads/zones/nodes/
structures, including the draft's own other vertices for closing polygons)
and edges (nearest point on any road segment or zone side, closing segment
included) so a road can run exactly along a zone border or two zones can
share an edge without gaps or overlap. Vertices beat edges near corners (an
edge must be at least twice as close as the nearest vertex to win); a
moving square preview shows where the click will land (emerald = vertex,
cyan = edge).
### Labels and styling on the live map

View file

@ -96,10 +96,10 @@ export const MapRoads: CollectionConfig = {
{
name: "labelVisible",
type: "checkbox",
defaultValue: true,
label: "Show Name Label",
admin: {
description: "Renders the road's name along the line at zoom-dependent intervals.",
description:
"Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.",
},
},
{

View file

@ -48,10 +48,12 @@ export const MapZones: CollectionConfig = {
options: [
{ label: "Water", value: "water" },
{ label: "Land", value: "land" },
{ label: "Territory", value: "territory" },
{ label: "Country Border", value: "border" },
],
admin: {
description:
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.",
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).",
},
},
{
@ -76,7 +78,8 @@ export const MapZones: CollectionConfig = {
min: 0,
max: 1,
admin: {
description: "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).",
description:
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).",
},
},
{
@ -114,7 +117,8 @@ export const MapZones: CollectionConfig = {
required: true,
validate: validatePolygon,
admin: {
description: "Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
description:
"Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
},
},
],

View file

@ -16,7 +16,7 @@ export type BlueprintTerrain = "land" | "water";
export interface ZoneInput {
id: number;
name?: string | null;
type: "water" | "land";
type: "water" | "land" | "territory" | "border";
/** Resource ids contained in this zone; a site inside satisfies their requirements. */
resources?: number[];
points: MapPoint[];
@ -112,8 +112,7 @@ export function checkResourcesInRange(
node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq,
);
const zoneMatch = zones.some(
(zone) =>
(zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
(zone) => (zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
);
if (!nodeMatch && !zoneMatch) {
return {

95
src/lib/map/snapping.ts Normal file
View file

@ -0,0 +1,95 @@
import { pointDistance, type MapPoint } from "./points";
export type SnapKind = "vertex" | "edge";
export interface SnapTarget {
key: string;
vertices: MapPoint[];
/** Polygon target: the closing segment (last -> first vertex) participates. */
closed: boolean;
/** Offer edge snapping (nearest point on each segment), not just vertices. */
edges: boolean;
}
export interface SnapMatch {
point: MapPoint;
kind: SnapKind;
targetKey: string;
/** Distance in meters from the cursor to the matched geometry. */
distance: number;
}
/** Nearest point to p on segment ab (projection clamped to the endpoints). */
export function closestPointOnSegment(p: MapPoint, a: MapPoint, b: MapPoint): MapPoint {
const abx = b[0] - a[0];
const aby = b[1] - a[1];
const lengthSq = abx * abx + aby * aby;
if (lengthSq === 0) return [a[0], a[1]];
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / lengthSq));
return [a[0] + abx * t, a[1] + aby * t];
}
/**
* Nearest snap within radiusMeters. Vertices own their neighborhood: an edge
* only wins when it is at least twice as close as the nearest vertex (clicks
* near a corner mean the corner, not one of the edges meeting there). Returns null when
* nothing is in range.
*/
export function findSnap(
point: MapPoint,
targets: readonly SnapTarget[],
radiusMeters: number,
): SnapMatch | null {
if (radiusMeters <= 0) return null;
let bestVertex: SnapMatch | null = null;
let bestEdge: SnapMatch | null = null;
for (const target of targets) {
for (const vertex of target.vertices) {
const distance = pointDistance(point, vertex);
if (distance <= radiusMeters && (!bestVertex || distance < bestVertex.distance)) {
bestVertex = {
point: [vertex[0], vertex[1]],
kind: "vertex",
targetKey: target.key,
distance,
};
}
}
if (!target.edges) continue;
const last = target.closed ? target.vertices.length : target.vertices.length - 1;
for (let i = 0; i < last; i++) {
const a = target.vertices[i];
const b = target.vertices[(i + 1) % target.vertices.length];
const projected = closestPointOnSegment(point, a, b);
const distance = pointDistance(point, projected);
if (distance <= radiusMeters && (!bestEdge || distance < bestEdge.distance)) {
bestEdge = { point: projected, kind: "edge", targetKey: target.key, distance };
}
}
}
if (bestVertex && (!bestEdge || bestEdge.distance >= bestVertex.distance * 0.5)) {
return bestVertex;
}
return bestEdge ?? bestVertex;
}
export interface SegmentMidpoint {
/** Insert position in the point array: i + 1 (appends for the closing segment). */
index: number;
point: MapPoint;
}
/** Midpoint of every segment, for click-to-insert vertex handles. */
export function segmentMidpoints(points: MapPoint[], closed: boolean): SegmentMidpoint[] {
const midpoints: SegmentMidpoint[] = [];
if (points.length < 2) return midpoints;
const segments = closed ? points.length : points.length - 1;
for (let i = 0; i < segments; i++) {
const a = points[i];
const b = points[(i + 1) % points.length];
midpoints.push({ index: i + 1, point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] });
}
return midpoints;
}

View file

@ -147,3 +147,37 @@ function segmentAngleDeg(from: MapPoint, to: MapPoint): number {
if (dx === 0 && dy === 0) return 0;
return (Math.atan2(dy, dx) * 180) / Math.PI;
}
/**
* CSS rotation for a straight label running along the from->to direction:
* normalized into [-90, 90] (never upside down) with vertical text reading
* bottom-to-top.
*/
export function labelScreenAngle(from: MapPoint, to: MapPoint): number {
const angle = segmentAngleDeg(from, to);
if (angle > 90) return angle - 180;
if (angle < -90) return angle + 180;
if (angle === 90) return -90;
return angle;
}
/**
* Minor roads (dirt/trail) only carry a name label this close to the ground,
* in meters per pixel (zoom >= 3 on an 8192 m world).
*/
export const MINOR_ROAD_LABEL_MAX_M_PER_PX = 4;
/**
* Tri-state road label rule: an explicit checkbox value wins; unset means
* auto, where paved roads always label and dirt/trail roads label only at
* close zoom (metersPerPx at or below MINOR_ROAD_LABEL_MAX_M_PER_PX; pass
* null/undefined to evaluate the zoom-independent part).
*/
export function roadLabelShown(
road: { labelVisible?: boolean | null; surface: string },
metersPerPx?: number | null,
): boolean {
if (road.labelVisible != null) return road.labelVisible;
if (road.surface === "paved") return true;
return metersPerPx != null && metersPerPx <= MINOR_ROAD_LABEL_MAX_M_PER_PX;
}

View file

@ -67,7 +67,7 @@ export interface ZoneFeature {
id: number;
name: string | null;
description: string | null;
type: "water" | "land";
type: "water" | "land" | "territory" | "border";
resources: { id: number; name: string }[];
fillColor: string | null;
strokeColor: string | null;

View file

@ -0,0 +1,29 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db }: MigrateUpArgs): Promise<void> {
// Political-boundary zone types (Postgres 12+ allows ADD VALUE inside the
// migration's transaction as long as the new values are not used in it).
await db.execute(sql`
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'territory';
`);
await db.execute(sql`
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'border';
`);
// Road labels become tri-state: NULL = auto (paved only). Existing rows all
// carry the old column default (true), which was never a deliberate choice,
// so clear it on non-paved roads to make the paved-only default real.
await db.execute(sql`
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" DROP DEFAULT;
`);
await db.execute(sql`
UPDATE "map_roads" SET "label_visible" = NULL WHERE "surface" IS DISTINCT FROM 'paved';
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Enum values cannot be removed in Postgres; restore the old road default.
await db.execute(sql`
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" SET DEFAULT true;
`);
}

View file

@ -1,399 +1,405 @@
import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260825_220000 from './20260825_220000';
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from './20260828_044506';
import * as migration_20260831_142503 from './20260831_142503';
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
import * as migration_20260903_035544 from './20260903_035544';
import * as migration_20260903_044047 from './20260903_044047';
import * as migration_20260903_053419 from './20260903_053419';
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
import * as migration_20260908_172241_add_custom_minigame_collections from './20260908_172241_add_custom_minigame_collections';
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field';
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
import * as migration_20260910_221018_make_missions_codename_optional from './20260910_221018_make_missions_codename_optional';
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference';
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
import * as migration_20260912_051752_add_rank_promotion_ceiling from './20260912_051752_add_rank_promotion_ceiling';
import * as migration_20260912_071740_add_blackjack_minigame_stats from './20260912_071740_add_blackjack_minigame_stats';
import * as migration_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at';
import * as migration_20260914_153046_add_chip_bank from './20260914_153046_add_chip_bank';
import * as migration_20260914_171412_add_respawn_tickets from './20260914_171412_add_respawn_tickets';
import * as migration_20260916_083158_add_game_map_system from './20260916_083158_add_game_map_system';
import * as migration_20260916_185544_construction_timestamp_model from './20260916_185544_construction_timestamp_model';
import * as migration_20260916_220224_add_map_labels_and_styling from './20260916_220224_add_map_labels_and_styling';
import * as migration_20260917_023028_add_node_and_zone_resources from './20260917_023028_add_node_and_zone_resources';
import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
import * as migration_20260827_005757 from "./20260827_005757";
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
import * as migration_20260828_044506 from "./20260828_044506";
import * as migration_20260831_142503 from "./20260831_142503";
import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
import * as migration_20260903_035544 from "./20260903_035544";
import * as migration_20260903_044047 from "./20260903_044047";
import * as migration_20260903_053419 from "./20260903_053419";
import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
import * as migration_20260905_213824 from "./20260905_213824";
import * as migration_20260905_215658 from "./20260905_215658";
import * as migration_20260906_045502 from "./20260906_045502";
import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional";
import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler";
import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description";
import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
import * as migration_20260907_090245_add_mission_ready_reminder_fields from "./20260907_090245_add_mission_ready_reminder_fields";
import * as migration_20260907_120754_add_story_beats from "./20260907_120754_add_story_beats";
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from "./20260907_124024_convert_story_beats_body_to_lexical";
import * as migration_20260908_152021_add_opord_recall_minigame_stats from "./20260908_152021_add_opord_recall_minigame_stats";
import * as migration_20260908_172241_add_custom_minigame_collections from "./20260908_172241_add_custom_minigame_collections";
import * as migration_20260908_193107_add_creator_xp_bank_field from "./20260908_193107_add_creator_xp_bank_field";
import * as migration_20260908_194435_add_expose_edge_mines_field from "./20260908_194435_add_expose_edge_mines_field";
import * as migration_20260909_153427_add_opord_recall_best_times from "./20260909_153427_add_opord_recall_best_times";
import * as migration_20260909_223955_add_voice_script_draft_status from "./20260909_223955_add_voice_script_draft_status";
import * as migration_20260910_221018_make_missions_codename_optional from "./20260910_221018_make_missions_codename_optional";
import * as migration_20260910_222518_add_dashboard_layout_preference from "./20260910_222518_add_dashboard_layout_preference";
import * as migration_20260911_044754_add_poker_minigame_stats from "./20260911_044754_add_poker_minigame_stats";
import * as migration_20260911_052147_add_ribbon_submissions from "./20260911_052147_add_ribbon_submissions";
import * as migration_20260911_201559_add_award_advancement_series from "./20260911_201559_add_award_advancement_series";
import * as migration_20260912_051752_add_rank_promotion_ceiling from "./20260912_051752_add_rank_promotion_ceiling";
import * as migration_20260912_071740_add_blackjack_minigame_stats from "./20260912_071740_add_blackjack_minigame_stats";
import * as migration_20260912_231219_add_discord_cancelled_at from "./20260912_231219_add_discord_cancelled_at";
import * as migration_20260914_153046_add_chip_bank from "./20260914_153046_add_chip_bank";
import * as migration_20260914_171412_add_respawn_tickets from "./20260914_171412_add_respawn_tickets";
import * as migration_20260916_083158_add_game_map_system from "./20260916_083158_add_game_map_system";
import * as migration_20260916_185544_construction_timestamp_model from "./20260916_185544_construction_timestamp_model";
import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling";
import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources";
import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types";
export const migrations = [
{
up: migration_20260820_004157.up,
down: migration_20260820_004157.down,
name: '20260820_004157',
name: "20260820_004157",
},
{
up: migration_20260825_220000.up,
down: migration_20260825_220000.down,
name: '20260825_220000',
name: "20260825_220000",
},
{
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: '20260826_050000_repair_shims_auxiliary_tables',
name: "20260826_050000_repair_shims_auxiliary_tables",
},
{
up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down,
name: '20260826_060000_add_game_servers_event_target',
name: "20260826_060000_add_game_servers_event_target",
},
{
up: migration_20260827_005757.up,
down: migration_20260827_005757.down,
name: '20260827_005757',
name: "20260827_005757",
},
{
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: '20260827_022334_add_eod_minesweeper_best_flags',
name: "20260827_022334_add_eod_minesweeper_best_flags",
},
{
up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down,
name: '20260827_213857_add_evaluations_collection',
name: "20260827_213857_add_evaluations_collection",
},
{
up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down,
name: '20260827_233224_add_mission_completed_at',
name: "20260827_233224_add_mission_completed_at",
},
{
up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down,
name: '20260828_011500_add_background_texture_preference',
name: "20260828_011500_add_background_texture_preference",
},
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode',
name: "20260828_041615_add_campaign_mode",
},
{
up: migration_20260828_044506.up,
down: migration_20260828_044506.down,
name: '20260828_044506',
name: "20260828_044506",
},
{
up: migration_20260831_142503.up,
down: migration_20260831_142503.down,
name: '20260831_142503',
name: "20260831_142503",
},
{
up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down,
name: '20260831_180233_assignment_transfers',
name: "20260831_180233_assignment_transfers",
},
{
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: '20260901_014006_add_assignment_transfer_event_target',
name: "20260901_014006_add_assignment_transfer_event_target",
},
{
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: '20260901_213108_add_ticket_votes_and_edit_history',
name: "20260901_213108_add_ticket_votes_and_edit_history",
},
{
up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down,
name: '20260901_222200_add_admin_page_permissions',
name: "20260901_222200_add_admin_page_permissions",
},
{
up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down,
name: '20260902_195336_add_medal_backgrounds',
name: "20260902_195336_add_medal_backgrounds",
},
{
up: migration_20260903_035544.up,
down: migration_20260903_035544.down,
name: '20260903_035544',
name: "20260903_035544",
},
{
up: migration_20260903_044047.up,
down: migration_20260903_044047.down,
name: '20260903_044047',
name: "20260903_044047",
},
{
up: migration_20260903_053419.up,
down: migration_20260903_053419.down,
name: '20260903_053419',
name: "20260903_053419",
},
{
up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down,
name: '20260903_060000_add_radio_mode_stats',
name: "20260903_060000_add_radio_mode_stats",
},
{
up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: '20260903_061000_add_radio_incorrect_counts',
name: "20260903_061000_add_radio_incorrect_counts",
},
{
up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down,
name: '20260903_062000_add_aim_trainer_stats',
name: "20260903_062000_add_aim_trainer_stats",
},
{
up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down,
name: '20260904_230838_add_wiki_collections',
name: "20260904_230838_add_wiki_collections",
},
{
up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down,
name: '20260905_183940_base_management',
name: "20260905_183940_base_management",
},
{
up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down,
name: '20260905_195456_labor_headcount',
name: "20260905_195456_labor_headcount",
},
{
up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down,
name: '20260905_210310_labor_classifications',
name: "20260905_210310_labor_classifications",
},
{
up: migration_20260905_213824.up,
down: migration_20260905_213824.down,
name: '20260905_213824',
name: "20260905_213824",
},
{
up: migration_20260905_215658.up,
down: migration_20260905_215658.down,
name: '20260905_215658',
name: "20260905_215658",
},
{
up: migration_20260906_045502.up,
down: migration_20260906_045502.down,
name: '20260906_045502',
name: "20260906_045502",
},
{
up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field',
name: "20260906_050900_add_evaluation_reminders_field",
},
{
up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down,
name: '20260906_061451_add_wiki_template_css',
name: "20260906_061451_add_wiki_template_css",
},
{
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: '20260906_063753_add_personal_excerpt_richtext',
name: "20260906_063753_add_personal_excerpt_richtext",
},
{
up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down,
name: '20260906_065405_add_wiki_template_html',
name: "20260906_065405_add_wiki_template_html",
},
{
up: migration_20260906_100908_add_market_economy_state.up,
down: migration_20260906_100908_add_market_economy_state.down,
name: '20260906_100908_add_market_economy_state',
name: "20260906_100908_add_market_economy_state",
},
{
up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down,
name: '20260906_165837_add_market_state_event_target',
name: "20260906_165837_add_market_state_event_target",
},
{
up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down,
name: '20260906_202925_add_voice_submissions',
name: "20260906_202925_add_voice_submissions",
},
{
up: migration_20260907_012326_add_missions_created_by.up,
down: migration_20260907_012326_add_missions_created_by.down,
name: '20260907_012326_add_missions_created_by',
name: "20260907_012326_add_missions_created_by",
},
{
up: migration_20260907_013724_make_missions_campaign_optional.up,
down: migration_20260907_013724_make_missions_campaign_optional.down,
name: '20260907_013724_make_missions_campaign_optional',
name: "20260907_013724_make_missions_campaign_optional",
},
{
up: migration_20260907_041051_add_voice_script_spoiler.up,
down: migration_20260907_041051_add_voice_script_spoiler.down,
name: '20260907_041051_add_voice_script_spoiler',
name: "20260907_041051_add_voice_script_spoiler",
},
{
up: migration_20260907_043339_add_voice_script_description.up,
down: migration_20260907_043339_add_voice_script_description.down,
name: '20260907_043339_add_voice_script_description',
name: "20260907_043339_add_voice_script_description",
},
{
up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
name: '20260907_044934_add_missions_discord_modlist_hash',
name: "20260907_044934_add_missions_discord_modlist_hash",
},
{
up: migration_20260907_083010_add_voice_submission_description.up,
down: migration_20260907_083010_add_voice_submission_description.down,
name: '20260907_083010_add_voice_submission_description',
name: "20260907_083010_add_voice_submission_description",
},
{
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
name: '20260907_090245_add_mission_ready_reminder_fields',
name: "20260907_090245_add_mission_ready_reminder_fields",
},
{
up: migration_20260907_120754_add_story_beats.up,
down: migration_20260907_120754_add_story_beats.down,
name: '20260907_120754_add_story_beats',
name: "20260907_120754_add_story_beats",
},
{
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
down: migration_20260907_124024_convert_story_beats_body_to_lexical.down,
name: '20260907_124024_convert_story_beats_body_to_lexical',
name: "20260907_124024_convert_story_beats_body_to_lexical",
},
{
up: migration_20260908_152021_add_opord_recall_minigame_stats.up,
down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
name: '20260908_152021_add_opord_recall_minigame_stats',
name: "20260908_152021_add_opord_recall_minigame_stats",
},
{
up: migration_20260908_172241_add_custom_minigame_collections.up,
down: migration_20260908_172241_add_custom_minigame_collections.down,
name: '20260908_172241_add_custom_minigame_collections',
name: "20260908_172241_add_custom_minigame_collections",
},
{
up: migration_20260908_193107_add_creator_xp_bank_field.up,
down: migration_20260908_193107_add_creator_xp_bank_field.down,
name: '20260908_193107_add_creator_xp_bank_field',
name: "20260908_193107_add_creator_xp_bank_field",
},
{
up: migration_20260908_194435_add_expose_edge_mines_field.up,
down: migration_20260908_194435_add_expose_edge_mines_field.down,
name: '20260908_194435_add_expose_edge_mines_field',
name: "20260908_194435_add_expose_edge_mines_field",
},
{
up: migration_20260909_153427_add_opord_recall_best_times.up,
down: migration_20260909_153427_add_opord_recall_best_times.down,
name: '20260909_153427_add_opord_recall_best_times',
name: "20260909_153427_add_opord_recall_best_times",
},
{
up: migration_20260909_223955_add_voice_script_draft_status.up,
down: migration_20260909_223955_add_voice_script_draft_status.down,
name: '20260909_223955_add_voice_script_draft_status',
name: "20260909_223955_add_voice_script_draft_status",
},
{
up: migration_20260910_221018_make_missions_codename_optional.up,
down: migration_20260910_221018_make_missions_codename_optional.down,
name: '20260910_221018_make_missions_codename_optional',
name: "20260910_221018_make_missions_codename_optional",
},
{
up: migration_20260910_222518_add_dashboard_layout_preference.up,
down: migration_20260910_222518_add_dashboard_layout_preference.down,
name: '20260910_222518_add_dashboard_layout_preference',
name: "20260910_222518_add_dashboard_layout_preference",
},
{
up: migration_20260911_044754_add_poker_minigame_stats.up,
down: migration_20260911_044754_add_poker_minigame_stats.down,
name: '20260911_044754_add_poker_minigame_stats',
name: "20260911_044754_add_poker_minigame_stats",
},
{
up: migration_20260911_052147_add_ribbon_submissions.up,
down: migration_20260911_052147_add_ribbon_submissions.down,
name: '20260911_052147_add_ribbon_submissions',
name: "20260911_052147_add_ribbon_submissions",
},
{
up: migration_20260911_201559_add_award_advancement_series.up,
down: migration_20260911_201559_add_award_advancement_series.down,
name: '20260911_201559_add_award_advancement_series',
name: "20260911_201559_add_award_advancement_series",
},
{
up: migration_20260912_051752_add_rank_promotion_ceiling.up,
down: migration_20260912_051752_add_rank_promotion_ceiling.down,
name: '20260912_051752_add_rank_promotion_ceiling',
name: "20260912_051752_add_rank_promotion_ceiling",
},
{
up: migration_20260912_071740_add_blackjack_minigame_stats.up,
down: migration_20260912_071740_add_blackjack_minigame_stats.down,
name: '20260912_071740_add_blackjack_minigame_stats',
name: "20260912_071740_add_blackjack_minigame_stats",
},
{
up: migration_20260912_231219_add_discord_cancelled_at.up,
down: migration_20260912_231219_add_discord_cancelled_at.down,
name: '20260912_231219_add_discord_cancelled_at',
name: "20260912_231219_add_discord_cancelled_at",
},
{
up: migration_20260914_153046_add_chip_bank.up,
down: migration_20260914_153046_add_chip_bank.down,
name: '20260914_153046_add_chip_bank',
name: "20260914_153046_add_chip_bank",
},
{
up: migration_20260914_171412_add_respawn_tickets.up,
down: migration_20260914_171412_add_respawn_tickets.down,
name: '20260914_171412_add_respawn_tickets',
name: "20260914_171412_add_respawn_tickets",
},
{
up: migration_20260916_083158_add_game_map_system.up,
down: migration_20260916_083158_add_game_map_system.down,
name: '20260916_083158_add_game_map_system',
name: "20260916_083158_add_game_map_system",
},
{
up: migration_20260916_185544_construction_timestamp_model.up,
down: migration_20260916_185544_construction_timestamp_model.down,
name: '20260916_185544_construction_timestamp_model',
name: "20260916_185544_construction_timestamp_model",
},
{
up: migration_20260916_220224_add_map_labels_and_styling.up,
down: migration_20260916_220224_add_map_labels_and_styling.down,
name: '20260916_220224_add_map_labels_and_styling',
name: "20260916_220224_add_map_labels_and_styling",
},
{
up: migration_20260917_023028_add_node_and_zone_resources.up,
down: migration_20260917_023028_add_node_and_zone_resources.down,
name: '20260917_023028_add_node_and_zone_resources'
name: "20260917_023028_add_node_and_zone_resources",
},
{
up: migration_20260917_112258_add_zone_political_types.up,
down: migration_20260917_112258_add_zone_political_types.down,
name: "20260917_112258_add_zone_political_types",
},
];

View file

@ -828,7 +828,17 @@ export const enum_releases_status = pgEnum("enum_releases_status", [
]);
export const enum_maps_basemap_mode = pgEnum("enum_maps_basemap_mode", ["image", "tiles"]);
export const enum_map_roads_surface = pgEnum("enum_map_roads_surface", ["paved", "dirt", "trail"]);
export const enum_map_zones_type = pgEnum("enum_map_zones_type", ["water", "land"]);
export const enum_map_roads_stroke_style = pgEnum("enum_map_roads_stroke_style", [
"solid",
"dashed",
"dotted",
]);
export const enum_map_zones_type = pgEnum("enum_map_zones_type", [
"water",
"land",
"territory",
"border",
]);
export const enum_narrative_events_triggers_type = pgEnum("enum_narrative_events_triggers_type", [
"manual",
"mission_complete",
@ -1066,6 +1076,8 @@ export const game_structures = pgTable(
}),
maintenanceCost: numeric("maintenance_cost", { mode: "number" }).default(0),
coordinates: geometryColumn("coordinates"),
labelVisible: boolean("label_visible").default(true),
labelPriority: numeric("label_priority", { mode: "number" }).default(0),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
@ -4787,6 +4799,7 @@ export const map_roads = pgTable(
{
id: serial("id").primaryKey(),
name: varchar("name"),
description: varchar("description"),
map: integer("map_id")
.notNull()
.references(() => maps.id, {
@ -4794,6 +4807,10 @@ export const map_roads = pgTable(
}),
surface: enum_map_roads_surface("surface").notNull().default("paved"),
speedMultiplier: numeric("speed_multiplier", { mode: "number" }).notNull().default(1),
strokeColor: varchar("stroke_color"),
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(2),
strokeStyle: enum_map_roads_stroke_style("stroke_style").default("solid"),
labelVisible: boolean("label_visible"),
points: jsonb("points").notNull(),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
@ -4814,12 +4831,18 @@ export const map_zones = pgTable(
{
id: serial("id").primaryKey(),
name: varchar("name"),
description: varchar("description"),
map: integer("map_id")
.notNull()
.references(() => maps.id, {
onDelete: "set null",
}),
type: enum_map_zones_type("type").notNull(),
fillColor: varchar("fill_color"),
strokeColor: varchar("stroke_color"),
fillOpacity: numeric("fill_opacity", { mode: "number" }),
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(1),
labelVisible: boolean("label_visible").default(true),
points: jsonb("points").notNull(),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
@ -4835,21 +4858,44 @@ export const map_zones = pgTable(
],
);
export const map_zones_rels = pgTable(
"map_zones_rels",
{
id: serial("id").primaryKey(),
order: integer("order"),
parent: integer("parent_id").notNull(),
path: varchar("path").notNull(),
resourcesID: integer("resources_id"),
},
(columns) => [
index("map_zones_rels_order_idx").on(columns.order),
index("map_zones_rels_parent_idx").on(columns.parent),
index("map_zones_rels_path_idx").on(columns.path),
index("map_zones_rels_resources_id_idx").on(columns.resourcesID),
foreignKey({
columns: [columns["parent"]],
foreignColumns: [map_zones.id],
name: "map_zones_rels_parent_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["resourcesID"]],
foreignColumns: [resources.id],
name: "map_zones_rels_resources_fk",
}).onDelete("cascade"),
],
);
export const resource_nodes = pgTable(
"resource_nodes",
{
id: serial("id").primaryKey(),
name: varchar("name").notNull(),
description: varchar("description"),
map: integer("map_id")
.notNull()
.references(() => maps.id, {
onDelete: "set null",
}),
resource: integer("resource_id")
.notNull()
.references(() => resources.id, {
onDelete: "set null",
}),
position: jsonb("position").notNull(),
richness: numeric("richness", { mode: "number" }).default(100),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
@ -4861,12 +4907,38 @@ export const resource_nodes = pgTable(
},
(columns) => [
index("resource_nodes_map_idx").on(columns.map),
index("resource_nodes_resource_idx").on(columns.resource),
index("resource_nodes_updated_at_idx").on(columns.updatedAt),
index("resource_nodes_created_at_idx").on(columns.createdAt),
],
);
export const resource_nodes_rels = pgTable(
"resource_nodes_rels",
{
id: serial("id").primaryKey(),
order: integer("order"),
parent: integer("parent_id").notNull(),
path: varchar("path").notNull(),
resourcesID: integer("resources_id"),
},
(columns) => [
index("resource_nodes_rels_order_idx").on(columns.order),
index("resource_nodes_rels_parent_idx").on(columns.parent),
index("resource_nodes_rels_path_idx").on(columns.path),
index("resource_nodes_rels_resources_id_idx").on(columns.resourcesID),
foreignKey({
columns: [columns["parent"]],
foreignColumns: [resource_nodes.id],
name: "resource_nodes_rels_parent_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["resourcesID"]],
foreignColumns: [resources.id],
name: "resource_nodes_rels_resources_fk",
}).onDelete("cascade"),
],
);
export const narrative_events = pgTable(
"narrative_events",
{
@ -8072,23 +8144,48 @@ export const relations_map_roads = relations(map_roads, ({ one }) => ({
relationName: "map",
}),
}));
export const relations_map_zones = relations(map_zones, ({ one }) => ({
export const relations_map_zones_rels = relations(map_zones_rels, ({ one }) => ({
parent: one(map_zones, {
fields: [map_zones_rels.parent],
references: [map_zones.id],
relationName: "_rels",
}),
resourcesID: one(resources, {
fields: [map_zones_rels.resourcesID],
references: [resources.id],
relationName: "resources",
}),
}));
export const relations_map_zones = relations(map_zones, ({ one, many }) => ({
map: one(maps, {
fields: [map_zones.map],
references: [maps.id],
relationName: "map",
}),
_rels: many(map_zones_rels, {
relationName: "_rels",
}),
}));
export const relations_resource_nodes = relations(resource_nodes, ({ one }) => ({
export const relations_resource_nodes_rels = relations(resource_nodes_rels, ({ one }) => ({
parent: one(resource_nodes, {
fields: [resource_nodes_rels.parent],
references: [resource_nodes.id],
relationName: "_rels",
}),
resourcesID: one(resources, {
fields: [resource_nodes_rels.resourcesID],
references: [resources.id],
relationName: "resources",
}),
}));
export const relations_resource_nodes = relations(resource_nodes, ({ one, many }) => ({
map: one(maps, {
fields: [resource_nodes.map],
references: [maps.id],
relationName: "map",
}),
resource: one(resources, {
fields: [resource_nodes.resource],
references: [resources.id],
relationName: "resource",
_rels: many(resource_nodes_rels, {
relationName: "_rels",
}),
}));
export const relations_narrative_events = relations(narrative_events, () => ({}));
@ -8757,6 +8854,7 @@ type DatabaseSchema = {
enum_releases_status: typeof enum_releases_status;
enum_maps_basemap_mode: typeof enum_maps_basemap_mode;
enum_map_roads_surface: typeof enum_map_roads_surface;
enum_map_roads_stroke_style: typeof enum_map_roads_stroke_style;
enum_map_zones_type: typeof enum_map_zones_type;
enum_narrative_events_triggers_type: typeof enum_narrative_events_triggers_type;
enum_story_beats_status: typeof enum_story_beats_status;
@ -8887,7 +8985,9 @@ type DatabaseSchema = {
maps: typeof maps;
map_roads: typeof map_roads;
map_zones: typeof map_zones;
map_zones_rels: typeof map_zones_rels;
resource_nodes: typeof resource_nodes;
resource_nodes_rels: typeof resource_nodes_rels;
narrative_events: typeof narrative_events;
story_beats: typeof story_beats;
story_beat_states: typeof story_beat_states;
@ -9032,7 +9132,9 @@ type DatabaseSchema = {
relations_labels: typeof relations_labels;
relations_maps: typeof relations_maps;
relations_map_roads: typeof relations_map_roads;
relations_map_zones_rels: typeof relations_map_zones_rels;
relations_map_zones: typeof relations_map_zones;
relations_resource_nodes_rels: typeof relations_resource_nodes_rels;
relations_resource_nodes: typeof relations_resource_nodes;
relations_narrative_events: typeof relations_narrative_events;
relations_story_beats: typeof relations_story_beats;

View file

@ -3791,7 +3791,7 @@ export interface MapRoad {
strokeWeight?: number | null;
strokeStyle?: ('solid' | 'dashed' | 'dotted') | null;
/**
* Renders the road's name along the line at zoom-dependent intervals.
* Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.
*/
labelVisible?: boolean | null;
/**
@ -3825,9 +3825,9 @@ export interface MapZone {
description?: string | null;
map: number | Map;
/**
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).
*/
type: 'water' | 'land';
type: 'water' | 'land' | 'territory' | 'border';
/**
* Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type.
*/
@ -3837,7 +3837,7 @@ export interface MapZone {
*/
strokeColor?: string | null;
/**
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).
*/
fillOpacity?: number | null;
/**

View file

@ -14,12 +14,20 @@ import type {
} from "@/payload-types";
import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing";
import {
labelScreenAngle,
roadLabelShown,
metersPerPx,
polygonCentroid,
roadLabelPoints,
splinePolyline,
validateHexColor,
} from "@/lib/map/style";
import {
closestPointOnSegment,
findSnap,
segmentMidpoints,
type SnapTarget,
} from "@/lib/map/snapping";
import {
checkResourcesInRange,
checkStructuresInRange,
@ -505,6 +513,28 @@ describe("Game Map System", () => {
expect(short).toEqual(raw.slice(0, 2));
});
it("derives upright straight-label angles with vertical text reading up", () => {
expect(labelScreenAngle([0, 0], [1000, 0])).toBe(0);
expect(labelScreenAngle([0, 0], [-1000, 0])).toBe(0);
expect(labelScreenAngle([0, 0], [0, 1000])).toBe(-90);
expect(labelScreenAngle([0, 0], [0, -1000])).toBe(-90);
expect(labelScreenAngle([0, 0], [1000, 1000])).toBe(45);
expect(labelScreenAngle([0, 0], [-1000, 1000])).toBe(-45);
});
it("defaults road labels to paved roads, minor roads at close zoom, with explicit override", () => {
expect(roadLabelShown({ labelVisible: null, surface: "paved" }, 32)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 32)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 32)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 4)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 3.9)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 4)).toBe(true);
expect(roadLabelShown({ labelVisible: true, surface: "dirt" }, 32)).toBe(true);
expect(roadLabelShown({ labelVisible: false, surface: "paved" }, 4)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "paved" })).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" })).toBe(false);
});
it("computes polygon centroids inside the ring", () => {
const centroid = polygonCentroid([
[0, 0],
@ -523,6 +553,82 @@ describe("Game Map System", () => {
});
});
describe("authoring snapping (pure)", () => {
it("projects to segments and clamps to endpoints", () => {
expect(closestPointOnSegment([500, 500], [0, 0], [1000, 0])).toEqual([500, 0]);
expect(closestPointOnSegment([-50, -50], [0, 0], [1000, 0])).toEqual([0, 0]);
expect(closestPointOnSegment([2000, 50], [0, 0], [1000, 0])).toEqual([1000, 0]);
expect(closestPointOnSegment([10, 10], [10, 10], [10, 10])).toEqual([10, 10]);
});
it("snaps to the nearest vertex, preferring vertices over edges on ties", () => {
const zone: SnapTarget = {
key: "zone-1",
vertices: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
closed: true,
edges: true,
};
const corner = findSnap([30, 20], [zone], 100);
expect(corner?.kind).toBe("vertex");
expect(corner?.point).toEqual([0, 0]);
// On the middle of an edge (>= 10% closer than any vertex) the edge wins.
const edge = findSnap([500, 30], [zone], 100);
expect(edge?.kind).toBe("edge");
expect(edge?.point).toEqual([500, 0]);
});
it("includes the closing segment of closed targets and respects the radius", () => {
const zone: SnapTarget = {
key: "zone-1",
vertices: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
closed: true,
edges: true,
};
const closing = findSnap([30, 500], [zone], 100);
expect(closing?.kind).toBe("edge");
expect(closing?.point).toEqual([0, 500]);
expect(findSnap([30, 500], [zone], 10)).toBeNull();
});
it("builds segment midpoints with insert indices for open and closed drafts", () => {
const open = segmentMidpoints(
[
[0, 0],
[100, 0],
[100, 100],
],
false,
);
expect(open).toEqual([
{ index: 1, point: [50, 0] },
{ index: 2, point: [100, 50] },
]);
const closed = segmentMidpoints(
[
[0, 0],
[100, 0],
[100, 100],
],
true,
);
expect(closed).toHaveLength(3);
expect(closed[2]).toEqual({ index: 3, point: [50, 50] });
});
});
describe("road routing (pure)", () => {
it("connects roads that share a vertex", () => {
const roads: RoadInput[] = [
@ -1083,6 +1189,98 @@ describe("Game Map System", () => {
expect(outside.success).toBe(false);
expect(outside.error).toContain("resource node");
});
it("accepts political-boundary zone types and leaves placement unaffected", async () => {
const territory = await payload.create({
collection: "map-zones",
data: {
map: mapA.id,
name: `${RUN} Territory`,
type: "territory",
points: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.zones.push(territory.id);
expect(territory.type).toBe("territory");
const border = await payload.create({
collection: "map-zones",
data: {
map: mapA.id,
name: `${RUN} Border`,
type: "border",
points: [
[4000, 6000],
[5000, 6000],
[5000, 7000],
[4000, 7000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.zones.push(border.id);
expect(border.type).toBe("border");
// Political zones are informational: a land blueprint inside one places
// normally (only water zones gate placement).
authSpy.mockResolvedValue({ user: logisticsUser });
const inside = await placeStructure({
mapId: mapA.id,
blueprintId: depotBlueprint.id,
name: `${RUN} Territory Site`,
x: 500,
y: 500,
});
expect(inside.success).toBe(true);
createdIds.gameStructures.push((inside.data as GameStructure).id);
});
it("stores road labels as tri-state with paved-only auto default", async () => {
const dirt = await payload.create({
collection: "map-roads",
data: {
map: mapA.id,
name: `${RUN} Dirt Track`,
surface: "dirt",
speedMultiplier: 1,
points: [
[0, 9000],
[1000, 9000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.roads.push(dirt.id);
expect(dirt.labelVisible).toBeNull();
const paved = await payload.create({
collection: "map-roads",
data: {
map: mapA.id,
name: `${RUN} Paved Way`,
surface: "paved",
speedMultiplier: 1,
labelVisible: false,
points: [
[0, 9500],
[1000, 9500],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.roads.push(paved.id);
expect(paved.labelVisible).toBe(false);
});
});
describe("construction timestamps", () => {