feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 2m10s
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:
@@ -834,6 +834,7 @@ function GameScreen({
|
||||
unowned={game.unowned ?? []}
|
||||
markers={game.markers ?? []}
|
||||
regionStats={game.regionStats ?? {}}
|
||||
contested={game.contested ?? []}
|
||||
onFocusNation={focusNation}
|
||||
/>
|
||||
|
||||
|
||||
+72
-18
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user