No man's land: TurnResult.unowned + release/re-claim in applyTurnResult + distinct grey map rendering (#16 P3)
- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey) - shared/types.ts: required GameState.unowned (cumulative mid-game releases; [] at init, distinct from preset-unassigned regions) - server/engine.ts: applyTurnResult applies releases first (sole-owner only, never fabricating no man's land from unassigned keys), then claims against the post-release snapshot (gain on an ownerless key = re-claim); lone release is isMeaningful; prompts + user payload carry the unowned field - server/stub.ts: unchanged behavior (never releases; e2e stays stable) - server/db.ts: legacy rows load with unowned defaulted to [] - server/index.ts: create-game sets []; jump reference table includes released keys so the model can name them for re-claim - WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip - unit tests: 105 -> 127 (release/re-claim suites, presentation contract, legacy-row normalization)
This commit is contained in:
@@ -653,6 +653,7 @@ function GameScreen({
|
||||
playerNationId={game.playerNationId}
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
unowned={game.unowned ?? []}
|
||||
onFocusNation={focusNation}
|
||||
/>
|
||||
|
||||
|
||||
+70
-17
@@ -8,7 +8,13 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||
maplibregl.setWorkerUrl(workerUrl);
|
||||
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
||||
import {
|
||||
mapFeatureOwner,
|
||||
regionFillStyle,
|
||||
unownedTooltipLabel,
|
||||
type RegionMap,
|
||||
type RegionOwner,
|
||||
} from '@shared/protocol';
|
||||
import type { Nation } from '@shared/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { idToKey } from '@/lib/regions';
|
||||
@@ -114,19 +120,27 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
||||
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
|
||||
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
|
||||
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
|
||||
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
||||
* - `stroke` – a per-border alpha (owned brightest, no man's land next,
|
||||
* plain-unowned faintest);
|
||||
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
||||
* player-highlight border).
|
||||
*
|
||||
* Crucially, the owner lookup keys the RegionMap by the stamped
|
||||
* `properties.regionKey`, **never** by the sub-region id or the repacked
|
||||
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
|
||||
*
|
||||
* (v0.22, #16 P3) Unowned regions have two looks, decided here (the
|
||||
* RegionMap simply has no entry for them): a key in `unownedSet` — released to
|
||||
* 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.
|
||||
*/
|
||||
function applyRegions(
|
||||
geo: FeatureCollection,
|
||||
regions: RegionMap,
|
||||
playerNationId: string,
|
||||
idKey: Record<string, string>,
|
||||
unownedSet: ReadonlySet<string>,
|
||||
): FeatureCollection {
|
||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||
for (const f of out.features) {
|
||||
@@ -141,14 +155,18 @@ function applyRegions(
|
||||
f.properties.regionKey = key;
|
||||
const r = key ? regions[key] : undefined;
|
||||
if (r) {
|
||||
f.properties.fill = r.color;
|
||||
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.stroke = 'rgba(255,255,255,0.30)';
|
||||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||||
} else {
|
||||
f.properties.fill = 'rgba(148,163,184,0.05)';
|
||||
f.properties.stroke = 'rgba(255,255,255,0.14)';
|
||||
// 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;
|
||||
@@ -188,14 +206,21 @@ function statRow(label: string, value?: number): string {
|
||||
/**
|
||||
* Compact hover tooltip: the sub-region's name, its **current owner**
|
||||
* (color-chipped), and 1–2 key stats. When the region's country has no owning
|
||||
* nation, a minimal "No owner" card is shown instead. The static
|
||||
* parent-country code (`properties.parent`) is deliberately NOT printed here —
|
||||
* it never updates when ownership is transferred, so only the live owner chip
|
||||
* (resolved from the RegionMap) is shown.
|
||||
* nation, a minimal card is shown instead; its sub-line says "No man's land"
|
||||
* for a region released mid-game (v0.22, #16 P3) and "No owner" for a region
|
||||
* no preset ever assigned. The static parent-country code
|
||||
* (`properties.parent`) is deliberately NOT printed here — it never updates
|
||||
* when ownership is transferred, so only the live owner chip (resolved from
|
||||
* the RegionMap) is shown.
|
||||
*/
|
||||
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
|
||||
function hoverHtml(
|
||||
subRegionName: string,
|
||||
region?: RegionOwner,
|
||||
nation?: Nation,
|
||||
noMansLand = false,
|
||||
): string {
|
||||
if (!region || !nation) {
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</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>`;
|
||||
}
|
||||
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
||||
return `<div class="hm-popup">
|
||||
@@ -213,6 +238,14 @@ interface Props {
|
||||
/** The active scenario id, for a sensible initial camera. */
|
||||
presetId: string;
|
||||
className?: string;
|
||||
/**
|
||||
* (v0.22, #16 P3) Region keys currently in no man's land (released
|
||||
* mid-game — `GameState.unowned`). Such regions render the distinct visible
|
||||
* grey (vs the faint preset-unassigned look of every other ownerless
|
||||
* region) and their hover tooltip says "No man's land" instead of "No
|
||||
* owner".
|
||||
*/
|
||||
unowned?: string[];
|
||||
/**
|
||||
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
||||
* dossier switches to that nation. The optional `info` carries the clicked
|
||||
@@ -246,6 +279,13 @@ interface Props {
|
||||
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
|
||||
* (no RegionMap entry for their key) simply do not change the dossier.
|
||||
*
|
||||
* (v0.22, #16 P3) An unowned region renders two distinct ways: a key in the
|
||||
* `unowned` prop (released to no man's land mid-game — `GameState.unowned`)
|
||||
* gets the distinct visible grey with a "No man's land" tooltip sub-line; every
|
||||
* other ownerless region (a preset never assigned it) keeps the faint look and
|
||||
* a plain "No owner" sub-line. The colors/labels come from the shared
|
||||
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
|
||||
*
|
||||
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||
* — no floating control and no external API.
|
||||
*/
|
||||
@@ -255,6 +295,7 @@ export default function WorldMap({
|
||||
nations,
|
||||
presetId,
|
||||
className,
|
||||
unowned,
|
||||
onFocusNation,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -268,11 +309,13 @@ export default function WorldMap({
|
||||
const nationsRef = useRef(nations);
|
||||
const playerRef = useRef(playerNationId);
|
||||
const presetRef = useRef(presetId);
|
||||
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
||||
const onFocusNationRef = useRef(onFocusNation);
|
||||
regionsRef.current = regions;
|
||||
nationsRef.current = nations;
|
||||
playerRef.current = playerNationId;
|
||||
presetRef.current = presetId;
|
||||
unownedRef.current = new Set(unowned ?? []);
|
||||
onFocusNationRef.current = onFocusNation;
|
||||
|
||||
// Diffing state for the transient territory-change flash (owned by this component
|
||||
@@ -396,9 +439,14 @@ export default function WorldMap({
|
||||
}
|
||||
const region = mapFeatureOwner(regionsRef.current, f);
|
||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||
// (v0.22, #16 P3) whether this region is no man's land (released
|
||||
// mid-game), so the ownerless tooltip can say so.
|
||||
const pKey = f.properties?.regionKey;
|
||||
const noMansLand =
|
||||
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
|
||||
hoverPopup
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation))
|
||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
|
||||
.addTo(map);
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
@@ -458,7 +506,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));
|
||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
|
||||
};
|
||||
|
||||
const ensureLayers = async () => {
|
||||
@@ -568,7 +616,9 @@ export default function WorldMap({
|
||||
// sub-region whose owner changed since the last update (a transient,
|
||||
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
|
||||
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed
|
||||
// key.
|
||||
// key. Re-runs when the no-man's-land set changes too (v0.22 #16 P3) so a
|
||||
// release/re-claim repaints the grey even if the RegionMap diff alone
|
||||
// wouldn't catch it.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const geo = baseGeoRef.current;
|
||||
@@ -578,10 +628,11 @@ export default function WorldMap({
|
||||
prevRegionsRef.current = cur;
|
||||
const wasPrimed = primedRef.current;
|
||||
if (Object.keys(cur).length > 0) primedRef.current = true;
|
||||
const unownedSet = new Set(unowned ?? []);
|
||||
|
||||
if (map && geo && idKey) {
|
||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey));
|
||||
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
|
||||
}
|
||||
|
||||
if (wasPrimed && prev && idKey) {
|
||||
@@ -589,9 +640,11 @@ export default function WorldMap({
|
||||
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||||
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
|
||||
}
|
||||
// A release (owned → unowned) and a re-claim (unowned → owned) both show
|
||||
// up as ownerId changes above, so they flash like any other transfer.
|
||||
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
||||
}
|
||||
}, [regions, playerNationId]);
|
||||
}, [regions, playerNationId, unowned]);
|
||||
|
||||
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
|
||||
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
||||
|
||||
Reference in New Issue
Block a user