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
|
- the game tick sweep (`processConstructionTick`) only completes: sites whose
|
||||||
`constructionCompletesAt` has passed flip to `complete` and emit
|
`constructionCompletesAt` has passed flip to `complete` and emit
|
||||||
`structure:construction-complete` (idempotent).
|
`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`
|
`structure:placed` is emitted at placement time. Blueprint `requiredTech`
|
||||||
stays display-only in the placement dialog (no research system exists).
|
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
|
Hidden, where Auto labels paved roads always and dirt/trail roads only at
|
||||||
close zoom, 4 m/px or finer); Save.
|
close zoom, 4 m/px or finer); Save.
|
||||||
- **New zone**: click at least 3 vertices; set name, description, type
|
- **New zone**: click at least 3 vertices; set name, description, type
|
||||||
(water/land/territory/border), and styling (fill color, edge color, fill
|
(water/land/territory/border/other), and styling (fill color, edge color,
|
||||||
opacity 0-1, edge width, name-label toggle); Save. Territory and Country
|
fill opacity 0-1, edge width, name-label toggle); Save. Territory and
|
||||||
Border are informational political boundaries (they never gate placement;
|
Country Border are informational political boundaries and Other is for
|
||||||
only water does) and default to amber 0.1 / violet 0.06 fill.
|
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,
|
- **New node**: click once to set the position; set name, description,
|
||||||
resource, richness; Save.
|
resource, richness; Save.
|
||||||
- Existing roads/zones/nodes show Edit + Delete buttons in their popup while
|
- 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`
|
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
|
0.25 fill, land `#4ade80` 0.08 fill). Invalid hex is rejected at save time
|
||||||
(both the authoring UI actions and the import route).
|
(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
|
### GeoJSON import
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,11 @@ export const MapZones: CollectionConfig = {
|
||||||
{ label: "Land", value: "land" },
|
{ label: "Land", value: "land" },
|
||||||
{ label: "Territory", value: "territory" },
|
{ label: "Territory", value: "territory" },
|
||||||
{ label: "Country Border", value: "border" },
|
{ label: "Country Border", value: "border" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
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,
|
max: 1,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
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;
|
setZoneName: (value: string) => void;
|
||||||
zoneDescription: string;
|
zoneDescription: string;
|
||||||
setZoneDescription: (value: string) => void;
|
setZoneDescription: (value: string) => void;
|
||||||
zoneType: "water" | "land" | "territory" | "border";
|
zoneType: "water" | "land" | "territory" | "border" | "other";
|
||||||
setZoneType: (value: "water" | "land" | "territory" | "border") => void;
|
setZoneType: (value: "water" | "land" | "territory" | "border" | "other") => void;
|
||||||
zoneLabelVisible: boolean;
|
zoneLabelVisible: boolean;
|
||||||
setZoneLabelVisible: (value: boolean) => void;
|
setZoneLabelVisible: (value: boolean) => void;
|
||||||
fillColor: string;
|
fillColor: string;
|
||||||
|
|
@ -112,7 +112,9 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
|
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
|
||||||
const [zoneName, setZoneName] = useState("");
|
const [zoneName, setZoneName] = useState("");
|
||||||
const [zoneDescription, setZoneDescription] = 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 [zoneLabelVisible, setZoneLabelVisible] = useState(true);
|
||||||
const [fillColor, setFillColor] = useState("");
|
const [fillColor, setFillColor] = useState("");
|
||||||
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
||||||
|
|
@ -711,7 +713,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
||||||
<Select
|
<Select
|
||||||
value={fields.zoneType}
|
value={fields.zoneType}
|
||||||
onValueChange={(value) =>
|
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">
|
<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="land">Land</SelectItem>
|
||||||
<SelectItem value="territory">Territory</SelectItem>
|
<SelectItem value="territory">Territory</SelectItem>
|
||||||
<SelectItem value="border">Country Border</SelectItem>
|
<SelectItem value="border">Country Border</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import type { GameStructure, Structure } from "@/payload-types";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { consumeStorage, relationshipId, storedAmount } from "@/lib/base/storage";
|
import { consumeStorage, relationshipId, storedAmount } from "@/lib/base/storage";
|
||||||
|
import { buildSpeedFactor, durationMinutesFor, siteCrewRows } from "@/lib/base/constructionSpeed";
|
||||||
|
|
||||||
export interface MaybeStartConstructionResult {
|
export interface MaybeStartConstructionResult {
|
||||||
started: boolean;
|
started: boolean;
|
||||||
/** Why the site did not start (undefined when started). */
|
/** 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 {
|
export interface ConstructionSweepResult {
|
||||||
|
|
@ -62,7 +63,10 @@ function materialShortfalls(blueprint: Structure, site: ConstructionSite): strin
|
||||||
function consumeMaterials(
|
function consumeMaterials(
|
||||||
blueprint: Structure,
|
blueprint: Structure,
|
||||||
site: ConstructionSite,
|
site: ConstructionSite,
|
||||||
): { storedResources: ConstructionSite["storedResources"]; voidStorage: ConstructionSite["voidStorage"] } {
|
): {
|
||||||
|
storedResources: ConstructionSite["storedResources"];
|
||||||
|
voidStorage: ConstructionSite["voidStorage"];
|
||||||
|
} {
|
||||||
let stored = [...(site.storedResources ?? [])];
|
let stored = [...(site.storedResources ?? [])];
|
||||||
let voidStorage = [...(site.voidStorage ?? [])];
|
let voidStorage = [...(site.voidStorage ?? [])];
|
||||||
|
|
||||||
|
|
@ -118,7 +122,14 @@ export async function maybeStartConstruction(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storedResources, voidStorage } = consumeMaterials(blueprint, site);
|
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({
|
await payload.update({
|
||||||
collection: "game-structures",
|
collection: "game-structures",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export type BlueprintTerrain = "land" | "water";
|
||||||
export interface ZoneInput {
|
export interface ZoneInput {
|
||||||
id: number;
|
id: number;
|
||||||
name?: string | null;
|
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. */
|
/** Resource ids contained in this zone; a site inside satisfies their requirements. */
|
||||||
resources?: number[];
|
resources?: number[];
|
||||||
points: MapPoint[];
|
points: MapPoint[];
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,139 @@ export function bearingDeg(from: MapPoint, to: MapPoint): number {
|
||||||
return (angle + 360) % 360;
|
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
|
* Ray-casting point-in-polygon test. The ring may be open (first vertex not
|
||||||
* repeated at the end); the closing edge is implied.
|
* 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++) {
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
const [xi, yi] = polygon[i];
|
const [xi, yi] = polygon[i];
|
||||||
const [xj, yj] = polygon[j];
|
const [xj, yj] = polygon[j];
|
||||||
const intersects =
|
const intersects = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
||||||
yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
|
||||||
if (intersects) inside = !inside;
|
if (intersects) inside = !inside;
|
||||||
}
|
}
|
||||||
return inside;
|
return inside;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
import { pointDistance, polylineLength, type MapPoint } from "./points";
|
import { pointDistance, polylineLength, type MapPoint } from "./points";
|
||||||
|
|
||||||
|
export { polygonCentroid } from "./points";
|
||||||
|
|
||||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
/** Field validator for optional hex colors (#rrggbb). Empty = use defaults. */
|
/** 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;
|
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
|
* Densifies a polyline with a Catmull-Rom spline (passing through every
|
||||||
* vertex) so rendered lines curve smoothly through bends instead of kinking
|
* vertex) so rendered lines curve smoothly through bends instead of kinking
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export interface StructureFeature {
|
||||||
storageCapacityKg: number | null;
|
storageCapacityKg: number | null;
|
||||||
labelVisible: boolean | null;
|
labelVisible: boolean | null;
|
||||||
labelPriority: number | null;
|
labelPriority: number | null;
|
||||||
|
parentId: number | null;
|
||||||
|
compoundChildren: number | null;
|
||||||
blueprintId: number | null;
|
blueprintId: number | null;
|
||||||
blueprintName: string | null;
|
blueprintName: string | null;
|
||||||
blueprintTerrain: "land" | "water" | null;
|
blueprintTerrain: "land" | "water" | null;
|
||||||
|
|
@ -67,7 +69,7 @@ export interface ZoneFeature {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type: "water" | "land" | "territory" | "border";
|
type: "water" | "land" | "territory" | "border" | "other";
|
||||||
resources: { id: number; name: string }[];
|
resources: { id: number; name: string }[];
|
||||||
fillColor: string | null;
|
fillColor: string | null;
|
||||||
strokeColor: 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,
|
Vehicle,
|
||||||
} from "@/payload-types";
|
} from "@/payload-types";
|
||||||
import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing";
|
import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing";
|
||||||
|
import { zoneLabelPoint } from "@/lib/map/points";
|
||||||
|
import { pointInPolygon } from "@/lib/map/points";
|
||||||
import {
|
import {
|
||||||
labelScreenAngle,
|
labelScreenAngle,
|
||||||
roadLabelShown,
|
roadLabelShown,
|
||||||
|
|
@ -36,6 +38,7 @@ import {
|
||||||
type StructureInput,
|
type StructureInput,
|
||||||
type ZoneInput,
|
type ZoneInput,
|
||||||
} from "@/lib/map/placement";
|
} from "@/lib/map/placement";
|
||||||
|
import { aggregateCompoundStorage, type CompoundMemberLike } from "@/lib/map/compounds";
|
||||||
import { maybeStartConstruction, processConstructionTick } from "@/lib/map/construction";
|
import { maybeStartConstruction, processConstructionTick } from "@/lib/map/construction";
|
||||||
import {
|
import {
|
||||||
placeStructure,
|
placeStructure,
|
||||||
|
|
@ -535,6 +538,41 @@ describe("Game Map System", () => {
|
||||||
expect(roadLabelShown({ labelVisible: null, surface: "dirt" })).toBe(false);
|
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", () => {
|
it("computes polygon centroids inside the ring", () => {
|
||||||
const centroid = polygonCentroid([
|
const centroid = polygonCentroid([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
|
|
@ -1190,7 +1228,7 @@ describe("Game Map System", () => {
|
||||||
expect(outside.error).toContain("resource node");
|
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({
|
const territory = await payload.create({
|
||||||
collection: "map-zones",
|
collection: "map-zones",
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -1229,6 +1267,25 @@ describe("Game Map System", () => {
|
||||||
createdIds.zones.push(border.id);
|
createdIds.zones.push(border.id);
|
||||||
expect(border.type).toBe("border");
|
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
|
// Political zones are informational: a land blueprint inside one places
|
||||||
// normally (only water zones gate placement).
|
// normally (only water zones gate placement).
|
||||||
authSpy.mockResolvedValue({ user: logisticsUser });
|
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", () => {
|
describe("construction timestamps", () => {
|
||||||
it("starts construction at delivery time and sweeps complete after the deadline", async () => {
|
it("starts construction at delivery time and sweeps complete after the deadline", async () => {
|
||||||
const site = await makeGameStructure({
|
const site = await makeGameStructure({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue