feat(map): zone label placement, political zone type, and authoring polish
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
f8ce3f3dcc
commit
7b82da58e7
10 changed files with 367 additions and 44 deletions
|
|
@ -88,6 +88,12 @@ runs on timestamps, not tick counts (src/lib/map/construction.ts):
|
|||
- the game tick sweep (`processConstructionTick`) only completes: sites whose
|
||||
`constructionCompletesAt` has passed flip to `complete` and emit
|
||||
`structure:construction-complete` (idempotent).
|
||||
- crew speed: blueprints with `constructionCrewRequired` > 0 refuse to start
|
||||
with no labor (`no_crew`) and build at the staffed fraction of full speed
|
||||
(floor 0.25); labor-classifications carry `buildEfficiency` per worker;
|
||||
`setLaborHeadcount` re-prices the remaining work on a building site via
|
||||
`applyCrewRateChange` (`src/lib/base/constructionSpeed.ts`): the finished
|
||||
fraction is preserved and the deadline pulls in or pushes out.
|
||||
|
||||
`structure:placed` is emitted at placement time. Blueprint `requiredTech`
|
||||
stays display-only in the placement dialog (no research system exists).
|
||||
|
|
@ -104,10 +110,11 @@ stays display-only in the placement dialog (no research system exists).
|
|||
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/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.
|
||||
(water/land/territory/border/other), and styling (fill color, edge color,
|
||||
fill opacity 0-1, edge width, name-label toggle); Save. Territory and
|
||||
Country Border are informational political boundaries and Other is for
|
||||
generic areas like named compounds (they never gate placement; only water
|
||||
does); defaults are amber 0.1, violet 0.06, and slate 0.08 respectively.
|
||||
- **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
|
||||
|
|
@ -165,6 +172,16 @@ stays display-only in the placement dialog (no research system exists).
|
|||
defaults (`paved` light gray, `dirt` tan, `trail` dimmer; water `#38bdf8`
|
||||
0.25 fill, land `#4ade80` 0.08 fill). Invalid hex is rejected at save time
|
||||
(both the authoring UI actions and the import route).
|
||||
- **Compounds**: structures may set `parentStructure` (a compound hub) at
|
||||
placement (dialog picker; same map, no nesting). Operational children pool
|
||||
storage into the hub: `/api/map-features` folds child storage totals into
|
||||
the hub feature, hides child map labels, and reports `compoundChildren`;
|
||||
the hub ribbon and popup footer show "Compound: N buildings". Aggregation
|
||||
math: `src/lib/map/compounds.ts`.
|
||||
- **Adjacency auras**: blueprints may declare an "Adjacency Aura"
|
||||
(production multiplier or capacity bonus over a meter range) that projects
|
||||
onto operational neighbors only; the production tick stacks the aura with
|
||||
module effects (`src/lib/base/adjacency.ts`).
|
||||
|
||||
### GeoJSON import
|
||||
|
||||
|
|
|
|||
|
|
@ -50,10 +50,11 @@ export const MapZones: CollectionConfig = {
|
|||
{ label: "Land", value: "land" },
|
||||
{ label: "Territory", value: "territory" },
|
||||
{ label: "Country Border", value: "border" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).",
|
||||
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational). Other is for generic areas such as named compounds.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -79,7 +80,7 @@ export const MapZones: CollectionConfig = {
|
|||
max: 1,
|
||||
admin: {
|
||||
description:
|
||||
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).",
|
||||
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border, 0.08 other).",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ export interface MapAuthoring {
|
|||
setZoneName: (value: string) => void;
|
||||
zoneDescription: string;
|
||||
setZoneDescription: (value: string) => void;
|
||||
zoneType: "water" | "land" | "territory" | "border";
|
||||
setZoneType: (value: "water" | "land" | "territory" | "border") => void;
|
||||
zoneType: "water" | "land" | "territory" | "border" | "other";
|
||||
setZoneType: (value: "water" | "land" | "territory" | "border" | "other") => void;
|
||||
zoneLabelVisible: boolean;
|
||||
setZoneLabelVisible: (value: boolean) => void;
|
||||
fillColor: string;
|
||||
|
|
@ -112,7 +112,9 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
|
||||
const [zoneName, setZoneName] = useState("");
|
||||
const [zoneDescription, setZoneDescription] = useState("");
|
||||
const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border">("water");
|
||||
const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border" | "other">(
|
||||
"water",
|
||||
);
|
||||
const [zoneLabelVisible, setZoneLabelVisible] = useState(true);
|
||||
const [fillColor, setFillColor] = useState("");
|
||||
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
||||
|
|
@ -711,7 +713,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
<Select
|
||||
value={fields.zoneType}
|
||||
onValueChange={(value) =>
|
||||
fields.setZoneType(value as "water" | "land" | "territory" | "border")
|
||||
fields.setZoneType(value as "water" | "land" | "territory" | "border" | "other")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
|
|
@ -722,6 +724,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
<SelectItem value="land">Land</SelectItem>
|
||||
<SelectItem value="territory">Territory</SelectItem>
|
||||
<SelectItem value="border">Country Border</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import type { GameStructure, Structure } from "@/payload-types";
|
|||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { consumeStorage, relationshipId, storedAmount } from "@/lib/base/storage";
|
||||
import { buildSpeedFactor, durationMinutesFor, siteCrewRows } from "@/lib/base/constructionSpeed";
|
||||
|
||||
export interface MaybeStartConstructionResult {
|
||||
started: boolean;
|
||||
/** Why the site did not start (undefined when started). */
|
||||
reason?: "not_awaiting" | "missing_materials" | "no_blueprint";
|
||||
reason?: "not_awaiting" | "missing_materials" | "no_blueprint" | "no_crew";
|
||||
}
|
||||
|
||||
export interface ConstructionSweepResult {
|
||||
|
|
@ -62,7 +63,10 @@ function materialShortfalls(blueprint: Structure, site: ConstructionSite): strin
|
|||
function consumeMaterials(
|
||||
blueprint: Structure,
|
||||
site: ConstructionSite,
|
||||
): { storedResources: ConstructionSite["storedResources"]; voidStorage: ConstructionSite["voidStorage"] } {
|
||||
): {
|
||||
storedResources: ConstructionSite["storedResources"];
|
||||
voidStorage: ConstructionSite["voidStorage"];
|
||||
} {
|
||||
let stored = [...(site.storedResources ?? [])];
|
||||
let voidStorage = [...(site.voidStorage ?? [])];
|
||||
|
||||
|
|
@ -118,7 +122,14 @@ export async function maybeStartConstruction(
|
|||
}
|
||||
|
||||
const { storedResources, voidStorage } = consumeMaterials(blueprint, site);
|
||||
const durationMinutes = Math.max(0, blueprint.constructionDurationMinutes ?? 0);
|
||||
let durationMinutes = Math.max(0, blueprint.constructionDurationMinutes ?? 0);
|
||||
const crewRequired = Math.max(0, blueprint.constructionCrewRequired ?? 0);
|
||||
if (crewRequired > 0) {
|
||||
const crew = await siteCrewRows(payload, site.id);
|
||||
const factor = buildSpeedFactor(crew, crewRequired);
|
||||
if (factor <= 0) return { started: false, reason: "no_crew" };
|
||||
durationMinutes = durationMinutesFor(durationMinutes, factor);
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type BlueprintTerrain = "land" | "water";
|
|||
export interface ZoneInput {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
type: "water" | "land" | "territory" | "border";
|
||||
type: "water" | "land" | "territory" | "border" | "other";
|
||||
/** Resource ids contained in this zone; a site inside satisfies their requirements. */
|
||||
resources?: number[];
|
||||
points: MapPoint[];
|
||||
|
|
|
|||
|
|
@ -107,6 +107,139 @@ export function bearingDeg(from: MapPoint, to: MapPoint): number {
|
|||
return (angle + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centroid of a polygon ring (area-weighted; falls back to the vertex average
|
||||
* for degenerate rings).
|
||||
*/
|
||||
export function polygonCentroid(points: MapPoint[]): MapPoint {
|
||||
if (points.length === 0) return [0, 0];
|
||||
let area = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const [xi, yi] = points[i];
|
||||
const [xj, yj] = points[j];
|
||||
const cross = xi * yj - xj * yi;
|
||||
area += cross;
|
||||
cx += (xj + xi) * cross;
|
||||
cy += (yj + yi) * cross;
|
||||
}
|
||||
area /= 2;
|
||||
if (Math.abs(area) < 1e-6) {
|
||||
const sumX = points.reduce((total, point) => total + point[0], 0);
|
||||
const sumY = points.reduce((total, point) => total + point[1], 0);
|
||||
return [sumX / points.length, sumY / points.length];
|
||||
}
|
||||
return [cx / (6 * area), cy / (6 * area)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed distance from a point to the ring's boundary: positive inside,
|
||||
* negative outside (unsigned distance to the nearest edge).
|
||||
*/
|
||||
function signedDistanceToRing(x: number, y: number, ring: MapPoint[]): number {
|
||||
let minSq = Infinity;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const ax = ring[j][0];
|
||||
const ay = ring[j][1];
|
||||
const bx = ring[i][0];
|
||||
const by = ring[i][1];
|
||||
const abx = bx - ax;
|
||||
const aby = by - ay;
|
||||
const lengthSq = abx * abx + aby * aby;
|
||||
let t = lengthSq > 0 ? ((x - ax) * abx + (y - ay) * aby) / lengthSq : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const dx = x - (ax + abx * t);
|
||||
const dy = y - (ay + aby * t);
|
||||
const sq = dx * dx + dy * dy;
|
||||
if (sq < minSq) minSq = sq;
|
||||
}
|
||||
const unsigned = Math.sqrt(minSq);
|
||||
return pointInPolygon([x, y], ring) ? unsigned : -unsigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label anchor for a zone ring: the area centroid when it lies inside with a
|
||||
* small margin from the edges, otherwise the nearest inside point that clears
|
||||
* the margin (grid sample plus local refinement). Keeps labels visually at the
|
||||
* zone's center of mass while guaranteeing they never sit outside or on the
|
||||
* boundary, even for concave or self-intersecting rings. Returns null for
|
||||
* degenerate rings.
|
||||
*/
|
||||
export function zoneLabelPoint(ring: MapPoint[]): MapPoint | null {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const [x, y] of ring) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
const margin = 0.02 * Math.min(width, height);
|
||||
const centroid = polygonCentroid(ring);
|
||||
if (signedDistanceToRing(centroid[0], centroid[1], ring) >= margin) {
|
||||
return centroid;
|
||||
}
|
||||
|
||||
// Grid sample the bbox; track the best inside cell by (margin satisfied,
|
||||
// then distance to centroid), refining locally around the winner.
|
||||
let best: { point: MapPoint; dist: number; bestOk: boolean } | null = null;
|
||||
const N = 48;
|
||||
const stepX = width / N;
|
||||
const stepY = height / N;
|
||||
for (let ix = 0; ix < N; ix++) {
|
||||
for (let iy = 0; iy < N; iy++) {
|
||||
const x = minX + stepX * (ix + 0.5);
|
||||
const y = minY + stepY * (iy + 0.5);
|
||||
const d = signedDistanceToRing(x, y, ring);
|
||||
if (d <= 0) continue;
|
||||
const dist = Math.hypot(x - centroid[0], y - centroid[1]);
|
||||
const ok = d >= margin;
|
||||
if (best === null || (ok && !best.bestOk) || (ok === best.bestOk && dist < best.dist)) {
|
||||
best = { point: [x, y], dist, bestOk: ok };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best === null) return null;
|
||||
|
||||
// Refine around the winner with a shrinking window.
|
||||
let spanX = stepX;
|
||||
let spanY = stepY;
|
||||
for (let level = 0; level < 3; level++) {
|
||||
const next: { point: MapPoint; dist: number; bestOk: boolean } = {
|
||||
point: best.point,
|
||||
dist: best.dist,
|
||||
bestOk: best.bestOk,
|
||||
};
|
||||
for (let dx = -3; dx <= 3; dx++) {
|
||||
for (let dy = -3; dy <= 3; dy++) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
const x = best.point[0] + spanX * (dx / 4);
|
||||
const y = best.point[1] + spanY * (dy / 4);
|
||||
const d = signedDistanceToRing(x, y, ring);
|
||||
if (d <= 0) continue;
|
||||
const dist = Math.hypot(x - centroid[0], y - centroid[1]);
|
||||
const ok = d >= margin;
|
||||
if ((ok && !next.bestOk) || (ok === next.bestOk && dist < next.dist)) {
|
||||
next.point = [x, y];
|
||||
next.dist = dist;
|
||||
next.bestOk = ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
best = next;
|
||||
spanX /= 4;
|
||||
spanY /= 4;
|
||||
}
|
||||
return best.point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ray-casting point-in-polygon test. The ring may be open (first vertex not
|
||||
* repeated at the end); the closing edge is implied.
|
||||
|
|
@ -118,8 +251,7 @@ export function pointInPolygon(point: MapPoint, polygon: MapPoint[]): boolean {
|
|||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const [xi, yi] = polygon[i];
|
||||
const [xj, yj] = polygon[j];
|
||||
const intersects =
|
||||
yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
||||
const intersects = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
import { pointDistance, polylineLength, type MapPoint } from "./points";
|
||||
|
||||
export { polygonCentroid } from "./points";
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
/** Field validator for optional hex colors (#rrggbb). Empty = use defaults. */
|
||||
|
|
@ -27,32 +29,6 @@ export function dashArrayFor(style: string | null | undefined): string | undefin
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centroid of a polygon ring (area-weighted; falls back to the vertex average
|
||||
* for degenerate rings).
|
||||
*/
|
||||
export function polygonCentroid(points: MapPoint[]): MapPoint {
|
||||
if (points.length === 0) return [0, 0];
|
||||
let area = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const [xi, yi] = points[i];
|
||||
const [xj, yj] = points[j];
|
||||
const cross = xi * yj - xj * yi;
|
||||
area += cross;
|
||||
cx += (xj + xi) * cross;
|
||||
cy += (yj + yi) * cross;
|
||||
}
|
||||
area /= 2;
|
||||
if (Math.abs(area) < 1e-6) {
|
||||
const sumX = points.reduce((total, point) => total + point[0], 0);
|
||||
const sumY = points.reduce((total, point) => total + point[1], 0);
|
||||
return [sumX / points.length, sumY / points.length];
|
||||
}
|
||||
return [cx / (6 * area), cy / (6 * area)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Densifies a polyline with a Catmull-Rom spline (passing through every
|
||||
* vertex) so rendered lines curve smoothly through bends instead of kinking
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export interface StructureFeature {
|
|||
storageCapacityKg: number | null;
|
||||
labelVisible: boolean | null;
|
||||
labelPriority: number | null;
|
||||
parentId: number | null;
|
||||
compoundChildren: number | null;
|
||||
blueprintId: number | null;
|
||||
blueprintName: string | null;
|
||||
blueprintTerrain: "land" | "water" | null;
|
||||
|
|
@ -67,7 +69,7 @@ export interface ZoneFeature {
|
|||
id: number;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
type: "water" | "land" | "territory" | "border";
|
||||
type: "water" | "land" | "territory" | "border" | "other";
|
||||
resources: { id: number; name: string }[];
|
||||
fillColor: string | null;
|
||||
strokeColor: string | null;
|
||||
|
|
|
|||
11
src/migrations/20260917_225116_add_zone_other_type.ts
Normal file
11
src/migrations/20260917_225116_add_zone_other_type.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'other';
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
// Enum values cannot be removed in Postgres.
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import type {
|
|||
Vehicle,
|
||||
} from "@/payload-types";
|
||||
import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing";
|
||||
import { zoneLabelPoint } from "@/lib/map/points";
|
||||
import { pointInPolygon } from "@/lib/map/points";
|
||||
import {
|
||||
labelScreenAngle,
|
||||
roadLabelShown,
|
||||
|
|
@ -36,6 +38,7 @@ import {
|
|||
type StructureInput,
|
||||
type ZoneInput,
|
||||
} from "@/lib/map/placement";
|
||||
import { aggregateCompoundStorage, type CompoundMemberLike } from "@/lib/map/compounds";
|
||||
import { maybeStartConstruction, processConstructionTick } from "@/lib/map/construction";
|
||||
import {
|
||||
placeStructure,
|
||||
|
|
@ -535,6 +538,41 @@ describe("Game Map System", () => {
|
|||
expect(roadLabelShown({ labelVisible: null, surface: "dirt" })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps zone label points inside and near the center of mass", () => {
|
||||
// C-shaped ring: the area centroid falls in the notch (outside).
|
||||
const cShape: [number, number][] = [
|
||||
[0, 0],
|
||||
[3000, 0],
|
||||
[3000, 1000],
|
||||
[1000, 1000],
|
||||
[1000, 2000],
|
||||
[3000, 2000],
|
||||
[3000, 3000],
|
||||
[0, 3000],
|
||||
];
|
||||
const centroid = polygonCentroid(cShape);
|
||||
expect(pointInPolygon(centroid, cShape)).toBe(false);
|
||||
|
||||
const label = zoneLabelPoint(cShape);
|
||||
expect(label).not.toBeNull();
|
||||
expect(pointInPolygon(label!, cShape)).toBe(true);
|
||||
|
||||
// Convex rings label at the centroid itself.
|
||||
const square: [number, number][] = [
|
||||
[0, 0],
|
||||
[2000, 0],
|
||||
[2000, 2000],
|
||||
[0, 2000],
|
||||
];
|
||||
const squareLabel = zoneLabelPoint(square);
|
||||
expect(squareLabel).not.toBeNull();
|
||||
expect(pointInPolygon(squareLabel!, square)).toBe(true);
|
||||
expect(squareLabel![0]).toBeGreaterThan(500);
|
||||
expect(squareLabel![0]).toBeLessThan(1500);
|
||||
expect(squareLabel![1]).toBeGreaterThan(500);
|
||||
expect(squareLabel![1]).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
it("computes polygon centroids inside the ring", () => {
|
||||
const centroid = polygonCentroid([
|
||||
[0, 0],
|
||||
|
|
@ -1190,7 +1228,7 @@ describe("Game Map System", () => {
|
|||
expect(outside.error).toContain("resource node");
|
||||
});
|
||||
|
||||
it("accepts political-boundary zone types and leaves placement unaffected", async () => {
|
||||
it("accepts political-boundary and generic zone types without gating placement", async () => {
|
||||
const territory = await payload.create({
|
||||
collection: "map-zones",
|
||||
data: {
|
||||
|
|
@ -1229,6 +1267,25 @@ describe("Game Map System", () => {
|
|||
createdIds.zones.push(border.id);
|
||||
expect(border.type).toBe("border");
|
||||
|
||||
const other = await payload.create({
|
||||
collection: "map-zones",
|
||||
data: {
|
||||
map: mapA.id,
|
||||
name: `${RUN} Compound`,
|
||||
type: "other",
|
||||
points: [
|
||||
[3000, 5000],
|
||||
[3800, 5000],
|
||||
[3800, 5600],
|
||||
[3000, 5600],
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
createdIds.zones.push(other.id);
|
||||
expect(other.type).toBe("other");
|
||||
|
||||
// Political zones are informational: a land blueprint inside one places
|
||||
// normally (only water zones gate placement).
|
||||
authSpy.mockResolvedValue({ user: logisticsUser });
|
||||
|
|
@ -1283,6 +1340,119 @@ describe("Game Map System", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("compound aggregation (pure)", () => {
|
||||
const member = (over: Partial<CompoundMemberLike> & { id: number }): CompoundMemberLike => ({
|
||||
constructionStatus: "complete",
|
||||
storageUsedKg: 0,
|
||||
storageCapacityKg: 1000,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("pools operational children into the hub and propagates uncapped members", () => {
|
||||
const hub = member({ id: 1, storageUsedKg: 300, storageCapacityKg: 1000 });
|
||||
const totals = aggregateCompoundStorage(hub, [
|
||||
member({ id: 2, storageUsedKg: 150, storageCapacityKg: 500 }),
|
||||
member({
|
||||
id: 3,
|
||||
storageUsedKg: 250,
|
||||
storageCapacityKg: 500,
|
||||
constructionStatus: "building",
|
||||
}),
|
||||
member({ id: 4, storageUsedKg: 90, storageCapacityKg: null }),
|
||||
]);
|
||||
expect(totals.storageUsedKg).toBe(540);
|
||||
expect(totals.storageCapacityKg).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps caps additive when every member is capped", () => {
|
||||
const hub = member({ id: 1, storageUsedKg: 300, storageCapacityKg: 1000 });
|
||||
expect(
|
||||
aggregateCompoundStorage(hub, [
|
||||
member({ id: 2, storageUsedKg: 100, storageCapacityKg: 500 }),
|
||||
]),
|
||||
).toEqual({ storageUsedKg: 400, storageCapacityKg: 1500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("compound placement", () => {
|
||||
it("stores the hub on a child placement", async () => {
|
||||
authSpy.mockResolvedValue({ user: logisticsUser });
|
||||
const hub = await makeGameStructure({
|
||||
name: `${RUN} Hub`,
|
||||
blueprintId: depotBlueprint.id,
|
||||
mapId: mapA.id,
|
||||
point: [3000, 3500],
|
||||
constructionStatus: "complete",
|
||||
});
|
||||
createdIds.gameStructures.push(hub.id);
|
||||
const result = await placeStructure({
|
||||
mapId: mapA.id,
|
||||
blueprintId: depotBlueprint.id,
|
||||
name: `${RUN} Annex`,
|
||||
parentStructureId: hub.id,
|
||||
x: 4500,
|
||||
y: 3500,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
const childId = (result.data as GameStructure).id;
|
||||
createdIds.gameStructures.push(childId);
|
||||
const child = (await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: childId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as GameStructure;
|
||||
expect(child.parentStructure).toBe(hub.id);
|
||||
});
|
||||
|
||||
it("refuses hubs on another map or hubs that are themselves children", async () => {
|
||||
authSpy.mockResolvedValue({ user: logisticsUser });
|
||||
const hub = await makeGameStructure({
|
||||
name: `${RUN} Far Hub`,
|
||||
blueprintId: depotBlueprint.id,
|
||||
mapId: mapB.id,
|
||||
point: [3000, 3500],
|
||||
constructionStatus: "complete",
|
||||
});
|
||||
createdIds.gameStructures.push(hub.id);
|
||||
const crossMap = await placeStructure({
|
||||
mapId: mapA.id,
|
||||
blueprintId: depotBlueprint.id,
|
||||
name: `${RUN} Cross Map Child`,
|
||||
parentStructureId: hub.id,
|
||||
x: 3200,
|
||||
y: 3500,
|
||||
});
|
||||
expect(crossMap.success).toBe(false);
|
||||
expect(crossMap.error).toContain("different map");
|
||||
|
||||
const childHub = (await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: `${RUN} Nested Hub`,
|
||||
type: depotBlueprint.id,
|
||||
map: mapA.id,
|
||||
coordinates: [3400, 3500],
|
||||
constructionStatus: "complete",
|
||||
parentStructure: hub.id,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as GameStructure;
|
||||
createdIds.gameStructures.push(childHub.id);
|
||||
const nested = await placeStructure({
|
||||
mapId: mapA.id,
|
||||
blueprintId: depotBlueprint.id,
|
||||
name: `${RUN} Nested Child`,
|
||||
parentStructureId: childHub.id,
|
||||
x: 3600,
|
||||
y: 3500,
|
||||
});
|
||||
expect(nested.success).toBe(false);
|
||||
expect(nested.error).toContain("cannot themselves be compound children");
|
||||
});
|
||||
});
|
||||
|
||||
describe("construction timestamps", () => {
|
||||
it("starts construction at delivery time and sweeps complete after the deadline", async () => {
|
||||
const site = await makeGameStructure({
|
||||
|
|
|
|||
Loading…
Reference in a new issue