1
0
Fork 0

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:
Jason Fraley 2026-09-20 02:43:35 -04:00
parent f8ce3f3dcc
commit 7b82da58e7
10 changed files with 367 additions and 44 deletions

View file

@ -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

View file

@ -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).",
},
},
{

View file

@ -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>

View file

@ -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",

View file

@ -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[];

View file

@ -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;

View file

@ -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

View file

@ -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;

View 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.
}

View file

@ -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({