From 7b82da58e7c3892e1797ad537c08fe5e45ea1203 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 20 Sep 2026 02:43:35 -0400 Subject: [PATCH] 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 --- docs/map-system.md | 25 ++- src/collections/world/MapZones.ts | 5 +- .../frontend/map/AuthoringToolbar.tsx | 11 +- src/lib/map/construction.ts | 17 +- src/lib/map/placement.ts | 2 +- src/lib/map/points.ts | 136 +++++++++++++- src/lib/map/style.ts | 28 +-- src/lib/map/types.ts | 4 +- .../20260917_225116_add_zone_other_type.ts | 11 ++ tests/int/map-system.int.spec.ts | 172 +++++++++++++++++- 10 files changed, 367 insertions(+), 44 deletions(-) create mode 100644 src/migrations/20260917_225116_add_zone_other_type.ts diff --git a/docs/map-system.md b/docs/map-system.md index 5445c54..1d44f49 100644 --- a/docs/map-system.md +++ b/docs/map-system.md @@ -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 diff --git a/src/collections/world/MapZones.ts b/src/collections/world/MapZones.ts index eb4ac84..65c03f7 100644 --- a/src/collections/world/MapZones.ts +++ b/src/collections/world/MapZones.ts @@ -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).", }, }, { diff --git a/src/components/frontend/map/AuthoringToolbar.tsx b/src/components/frontend/map/AuthoringToolbar.tsx index 9157e9f..fc11f8b 100644 --- a/src/components/frontend/map/AuthoringToolbar.tsx +++ b/src/components/frontend/map/AuthoringToolbar.tsx @@ -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(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 diff --git a/src/lib/map/construction.ts b/src/lib/map/construction.ts index 07923da..24f9c71 100644 --- a/src/lib/map/construction.ts +++ b/src/lib/map/construction.ts @@ -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", diff --git a/src/lib/map/placement.ts b/src/lib/map/placement.ts index 81ac591..2ade687 100644 --- a/src/lib/map/placement.ts +++ b/src/lib/map/placement.ts @@ -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[]; diff --git a/src/lib/map/points.ts b/src/lib/map/points.ts index a3f40c6..54b5ec5 100644 --- a/src/lib/map/points.ts +++ b/src/lib/map/points.ts @@ -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; diff --git a/src/lib/map/style.ts b/src/lib/map/style.ts index 351bd3a..e2be88b 100644 --- a/src/lib/map/style.ts +++ b/src/lib/map/style.ts @@ -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 diff --git a/src/lib/map/types.ts b/src/lib/map/types.ts index bd5a963..7f8a60a 100644 --- a/src/lib/map/types.ts +++ b/src/lib/map/types.ts @@ -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; diff --git a/src/migrations/20260917_225116_add_zone_other_type.ts b/src/migrations/20260917_225116_add_zone_other_type.ts new file mode 100644 index 0000000..a2cf1fe --- /dev/null +++ b/src/migrations/20260917_225116_add_zone_other_type.ts @@ -0,0 +1,11 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres"; + +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'other'; + `); +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Enum values cannot be removed in Postgres. +} diff --git a/tests/int/map-system.int.spec.ts b/tests/int/map-system.int.spec.ts index 37e5752..9373dde 100644 --- a/tests/int/map-system.int.spec.ts +++ b/tests/int/map-system.int.spec.ts @@ -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 & { 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({