feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 2m10s

A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
This commit is contained in:
2026-10-16 15:21:45 +00:00
parent d946fdf0d7
commit 5477336537
27 changed files with 1743 additions and 80 deletions
+1
View File
@@ -834,6 +834,7 @@ function GameScreen({
unowned={game.unowned ?? []}
markers={game.markers ?? []}
regionStats={game.regionStats ?? {}}
contested={game.contested ?? []}
onFocusNation={focusNation}
/>
+72 -18
View File
@@ -9,11 +9,15 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl);
import {
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
mapFeatureOwner,
MARKER_TYPE_INFO,
nationColor,
regionFillStyle,
unownedTooltipLabel,
type ContestedRegion,
type RegionMap,
type RegionOwner,
} from '@shared/protocol';
@@ -146,6 +150,12 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
*
* (v0.44.0, #66) A **contested** region (a war frontline — `contestedSet`, from
* `GameState.contested`) takes precedence over both unowned looks: it is in no
* nation's territory and not no man's land, so it renders the distinct
* reddish/orange `CONTESTED_FILL` / `CONTESTED_STROKE`. The state is decided by
* the client, never by the RegionMap (which has no entry for a contested key).
*/
function applyRegions(
geo: FeatureCollection,
@@ -153,6 +163,7 @@ function applyRegions(
playerNationId: string,
idKey: Record<string, string>,
unownedSet: ReadonlySet<string>,
contestedSet: ReadonlySet<string>,
): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) {
@@ -165,23 +176,36 @@ function applyRegions(
// key the RegionMap by it — never the sub-region id or the repacked id.
const key = idKey[subId] ?? '';
f.properties.regionKey = key;
const r = key ? regions[key] : undefined;
if (r) {
const style = regionFillStyle(r, false);
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId;
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else {
// Ownerless: visible grey only for keys released mid-game (no man's
// land); a preset that never assigned this sub-region stays faint.
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
// (v0.44.0, #66) A contested frontline takes precedence over every other
// state (owned, unowned, no man's land): the key is in no nation's
// territory and not no man's land, so it renders the distinct contested
// fill/stroke (decided by the client from `GameState.contested`, never by
// the RegionMap, which has no entry for it).
if (key !== '' && contestedSet.has(key)) {
f.properties.fill = CONTESTED_FILL;
f.properties.stroke = CONTESTED_STROKE;
f.properties.owner = '';
f.properties.ownerId = '';
f.properties.isPlayer = 0;
} else {
const r = key ? regions[key] : undefined;
if (r) {
const style = regionFillStyle(r, false);
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId;
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else {
// Ownerless: visible grey only for keys released mid-game (no man's
// land); a preset that never assigned this sub-region stays faint.
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = '';
f.properties.ownerId = '';
f.properties.isPlayer = 0;
}
}
}
return out;
@@ -412,8 +436,14 @@ function hoverHtml(
noMansLand = false,
regionKey?: string,
regionStats?: Record<string, RegionStats>,
contestedParties?: string[],
): string {
if (!region || !nation) {
// (v0.44.0, #66) A contested frontline (a region in no nation's territory
// and not no man's land) names its contesting parties in the sub-line.
if (contestedParties && contestedParties.length > 0) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Contested')}</div><div class="hm-popup-sub">${contestedTooltipLabel(contestedParties)}</div></div>`;
}
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
}
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
@@ -479,6 +509,15 @@ interface Props {
* stats.
*/
regionStats?: Record<string, RegionStats>;
/**
* (v0.44.0, #66) The game's contested regions / war frontlines
* (`GameState.contested` — a region key + its contesting party ids). Such a
* region is in no nation's territory and not no man's land (a third region
* state): it renders the distinct reddish/orange `CONTESTED_FILL` and its
* hover tooltip names the parties. The map legend is unaffected (a
* contested key is absent from the RegionMap, so it adds no entry).
*/
contested?: ContestedRegion[];
}
/**
@@ -536,6 +575,7 @@ export default function WorldMap({
onFocusNation,
markers,
regionStats,
contested,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -553,6 +593,10 @@ export default function WorldMap({
const markersRef = useRef<MapMarker[]>(markers ?? []);
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
// (v0.44.0, #66) latest contested regions, region key -> contesting party
// NAMES (the stored parties are resolved nation ids, resolved here so the
// hover tooltip can show "Contested: A vs B").
const contestedRef = useRef<Map<string, string[]>>(new Map());
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
@@ -561,6 +605,11 @@ export default function WorldMap({
onFocusNationRef.current = onFocusNation;
markersRef.current = markers ?? [];
regionStatsRef.current = regionStats ?? {};
{
const m = new Map<string, string[]>();
for (const c of contested ?? []) m.set(c.regionKey, c.parties.map((id) => nations[id]?.name ?? id));
contestedRef.current = m;
}
// Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes).
@@ -721,9 +770,12 @@ export default function WorldMap({
const pKey = f.properties?.regionKey;
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
// (v0.44.0, #66) a contested frontline (in no territory, not no man's
// land) names its contesting parties in the tooltip sub-line.
const contestedParties = !region && keyStr !== undefined ? contestedRef.current.get(keyStr) : undefined;
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current, contestedParties))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -799,7 +851,7 @@ export default function WorldMap({
const idKey = idKeyRef.current;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !idKey || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current, new Set(contestedRef.current.keys())));
};
// Stamp the current markers onto the marker source (v0.27, #44).
@@ -977,7 +1029,7 @@ export default function WorldMap({
if (map && geo && idKey) {
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet, new Set(contestedRef.current.keys())));
}
if (wasPrimed && prev && idKey) {
@@ -989,7 +1041,9 @@ export default function WorldMap({
// up as ownerId changes above, so they flash like any other transfer.
if (changed.size > 0) flashApiRef.current?.([...changed]);
}
}, [regions, playerNationId, unowned]);
// (v0.44.0, #66) re-paint when the contested set changes too — a contested
// key is absent from the RegionMap, so the diff alone wouldn't repaint it.
}, [regions, playerNationId, unowned, contested]);
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
// changes (a turn placed/removed markers) or the nations record changes