From 486af647d8f1d4da7356a86f0bbf95af9ea730c0 Mon Sep 17 00:00:00 2001 From: Justrada Date: Wed, 29 Jul 2026 00:49:20 -0400 Subject: [PATCH 1/4] Battlefield structure: walls as edges, line of sight, custom arenas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the map feature: a fight can now happen in a *place* rather than only in the default open rectangle. `CombatState.battlefield` is optional, so every existing fight is byte-for-byte unchanged — omitting it reproduces the old behaviour exactly, which is why all 311 previous tests pass untouched. Walls are stored as EDGES between hexes, not as blocked hexes. Measured on the real 18x14 board, a 7-room interior costs ~100 edge records and zero standable ground, versus consuming ~93 of 252 hexes (37% of the board) if walls were cells. It also gives doors the right arity: an edge door connects exactly two hexes, where a door *hex* would leak in six directions. Foundry VTT and Roll20 both model walls as segments for the same reasons. - `edgeId(hex, dir)` packs a border into one integer, with each hex owning three of its six edges so both sides agree on the id. Verified injective. - `Battlefield` stays JSON-safe (arrays, string-keyed doors) because it rides inside CombatState over PeerJS and into localStorage; `compileTerrain` builds the Set/Map lookups the engine probes. - `hasLineOfSight` walks the existing `hexLineDraw`. It is deliberately permissive: that function's rounding is not symmetric (8 of 31,626 hex pairs on this board disagree), so sight is granted if either direction is clear. Players forgive "they shouldn't have seen me" more than "I can't shoot the thing I'm looking at". - Threaded terrain through Move, Flee, Chase, targeting and AoE — and through forced movement, which walks its own step loop and would otherwise have been the one way to shove someone through solid stone. - Out-of-range and behind-cover now read differently in the log. - `deployHexes` accepts explicit zones. The row-based default reserves 8 of 14 rows, which is most of a small board and meaningless on an irregular one. - `normalizeBattlefield` clamps dims at the trust boundary; an inbound 100000x100000 arena would hang the client before it was ever playable. Co-Authored-By: Claude Opus 4.8 --- src/engine/combat.ts | 98 +++++++++++-- src/engine/combatTerrain.test.ts | 190 ++++++++++++++++++++++++ src/engine/hex.ts | 190 ++++++++++++++++++++++-- src/engine/hexTerrain.test.ts | 245 +++++++++++++++++++++++++++++++ src/engine/index.ts | 12 ++ src/lib/combat.test.ts | 60 +++++++- src/lib/combat.ts | 81 +++++++++- src/types/combat.ts | 41 ++++++ 8 files changed, 888 insertions(+), 29 deletions(-) create mode 100644 src/engine/combatTerrain.test.ts create mode 100644 src/engine/hexTerrain.test.ts diff --git a/src/engine/combat.ts b/src/engine/combat.ts index 25f9605..273910f 100644 --- a/src/engine/combat.ts +++ b/src/engine/combat.ts @@ -31,6 +31,10 @@ import { } from './effects'; import { closestReachableTo, + compileTerrain, + dirIndex, + edgeBlocks, + hasLineOfSight, hexDistance, hexEquals, hexLineDraw, @@ -38,6 +42,8 @@ import { reachableHexes, stepAway, stepToward, + type GridDims, + type Terrain, } from './hex'; import type { AdvantageMode } from '@/types'; @@ -81,6 +87,27 @@ export interface RoundStep { // Small pure utilities // --------------------------------------------------------------------------- +/** + * The arena this fight uses. A fight staged from the atlas carries its own + * {@link Battlefield}; every other fight gets the default open rectangle, so + * omitting it reproduces the game's original behaviour exactly. + */ +function dimsOf(state: CombatState): GridDims { + return state.battlefield?.dims ?? BATTLE_GRID; +} + +/** + * This arena's walls/doors/solid hexes, compiled for lookup — or `undefined` for + * an open field, which short-circuits every structure check downstream. + * + * Recompiled per call rather than cached: a fully-walled board is ~100 entries, + * so this costs microseconds, and a cache would be module state the pure engine + * is better off without. + */ +function terrainOf(state: CombatState): Terrain | undefined { + return compileTerrain(state.battlefield); +} + /** Structured deep clone of combat state, so resolution never mutates input. */ function cloneState(state: CombatState): CombatState { return { @@ -468,13 +495,16 @@ export function isTargetInRange( source: Combatant, target: Combatant | undefined, range: string | undefined, + terrain?: Terrain, ): boolean { if (!source || !target) return false; if (range === 'Self') return target.team === source.team; const max = range ? RANGE_TO_HEX_DISTANCE[range] : undefined; if (max === undefined) return false; - if (max === Infinity) return true; - return hexDistance(source.position, target.position) <= max; + if (max !== Infinity && hexDistance(source.position, target.position) > max) return false; + // Walls beat range: you cannot strike, or shoot, through a wall you can't see + // past. On an open field (no terrain) this is always true, so nothing changes. + return hasLineOfSight(source.position, target.position, terrain); } /** True when a usable only carries supportive (ally-targeting) effects. */ @@ -536,13 +566,16 @@ export function getAOETargets( if (!primary || !aoe || aoe === 'Single Target') return [primary]; const supportive = isSupportive(data); + const terrain = terrainOf(state); const targets: Combatant[] = []; // Area effects are team-agnostic: a blast catches allies and enemies alike. const eligible = (c: Combatant): boolean => { if (c.currentHP <= 0 && !c.isUnconscious) return false; // removed from the fight if (!supportive && c.isUnconscious) return false; // damage spares the downed - return true; + // A blast fills the room it goes off in, not the one next door — anyone the + // impact point can't see is behind a wall and is spared. + return hasLineOfSight(primary.position, c.position, terrain); }; if (aoe.includes('AOE')) { @@ -590,6 +623,8 @@ function moveTarget( distance: number, ): MoveOutcome { const from = { ...target.position }; + const dims = dimsOf(state); + const terrain = terrainOf(state); const occupied = (h: HexCoord): boolean => state.combatants.some( @@ -615,7 +650,14 @@ function moveTarget( next = stepAway(cursor, source.position); } if (hexEquals(next, cursor)) break; - if (!inBounds(next, BATTLE_GRID)) { + // A shove stops at a wall exactly as it stops at the grid edge — otherwise + // forced movement is the one way to put a combatant through solid stone. + const d = dirIndex(cursor, next); + if (d < 0 || edgeBlocks(terrain, cursor, d)) { + blockedReason = 'boundary'; + break; + } + if (!inBounds(next, dims) || (terrain && terrain.solid.has(`${next.q},${next.r}`))) { blockedReason = 'boundary'; break; } @@ -1424,7 +1466,14 @@ function resolveMove( return; } - const dest = closestReachableTo(source.position, goal, MOVE_RANGE, blocked, BATTLE_GRID); + const dest = closestReachableTo( + source.position, + goal, + MOVE_RANGE, + blocked, + dimsOf(state), + terrainOf(state), + ); // When approaching a specific combatant, don't end on top of it: if the closest // reachable hex is the target's own hex, fall back toward an adjacent hex. @@ -1464,7 +1513,13 @@ function resolveFlee( // nearest safe spot the move allowance can reach, even if it means sidestepping. let best = source.position; let bestD = hexDistance(source.position, target.position); - for (const c of reachableHexes(source.position, MOVE_RANGE, blocked, BATTLE_GRID)) { + for (const c of reachableHexes( + source.position, + MOVE_RANGE, + blocked, + dimsOf(state), + terrainOf(state), + )) { const d = hexDistance(c, target.position); if (d > bestD) { bestD = d; @@ -1500,7 +1555,14 @@ function resolveChase( } const blocked = blockedBy(state, source); - const dest = closestReachableTo(source.position, target.position, MOVE_RANGE, blocked, BATTLE_GRID); + const dest = closestReachableTo( + source.position, + target.position, + MOVE_RANGE, + blocked, + dimsOf(state), + terrainOf(state), + ); // Don't end on top of the target — fall back to holding if the only closer hex // is the target's own. const finalDest = hexEquals(dest, target.position) ? source.position : dest; @@ -1575,9 +1637,14 @@ function resolveUsable( return; } - if (!isTargetInRange(source, target, data.range)) { - pushLog(log, round, `${source.name} tries to use ${name}, but ${target?.name ?? 'the target'} is out of range.`, 'muted'); - results.push({ kind: 'info', text: `Out of range`, targetId: target?.id }); + const terrain = terrainOf(state); + if (!isTargetInRange(source, target, data.range, terrain)) { + // Distinguish the two failures — "out of range" for a target the caster + // simply can't reach, "behind cover" for one a wall is in the way of. + const unseen = target != null && !hasLineOfSight(source.position, target.position, terrain); + const why = unseen ? 'behind cover' : 'out of range'; + pushLog(log, round, `${source.name} tries to use ${name}, but ${target?.name ?? 'the target'} is ${why}.`, 'muted'); + results.push({ kind: 'info', text: unseen ? 'No line of sight' : 'Out of range', targetId: target?.id }); return; } @@ -1825,8 +1892,15 @@ export function resolveAction( pushLog(log, round, `${source.name} tries to attack, but ${target.name} is down.`, 'muted'); break; } - if (!isTargetInRange(source, target, weapon.range)) { - pushLog(log, round, `${source.name} tries to attack, but ${target.name} is out of range.`, 'muted'); + const attackTerrain = terrainOf(state); + if (!isTargetInRange(source, target, weapon.range, attackTerrain)) { + const unseen = !hasLineOfSight(source.position, target.position, attackTerrain); + pushLog( + log, + round, + `${source.name} tries to attack, but ${target.name} is ${unseen ? 'behind cover' : 'out of range'}.`, + 'muted', + ); break; } const usesAmmo = typeof weapon.clipSize === 'number' && weapon.clipSize > 0; diff --git a/src/engine/combatTerrain.test.ts b/src/engine/combatTerrain.test.ts new file mode 100644 index 0000000..e2cea88 --- /dev/null +++ b/src/engine/combatTerrain.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import type { Battlefield, Combatant, CombatState, ResolvedAction, SkillNode } from '@/types'; +import { isTargetInRange, resolveAction } from './combat'; +import { compileTerrain, edgeId, hexKey } from './hex'; +import { setActiveCatalog, buildActiveCatalog, resetActiveCatalog } from '@/data/skillTree'; + +const at = (q: number, r: number) => ({ q, r }); + +const mkCombatant = (o: Partial & { id: string }): Combatant => ({ + peerId: null, + name: o.id, + team: 'player', + position: at(0, 0), + initiativeBonus: 0, + maxHP: 20, + maxMP: 5, + maxSP: 5, + currentHP: 20, + currentMP: 5, + currentSP: 5, + ac: 10, + statusEffects: [], + isUnconscious: false, + isDead: false, + deathSaves: { successes: 0, failures: 0 }, + ...o, +}); + +const mkState = (combatants: Combatant[], battlefield?: Battlefield): CombatState => ({ + isActive: true, + phase: 'declare', + round: 1, + battlefield, + combatants, + declaredActions: {}, + lockedActions: {}, + resolutionQueue: [], + activeResolutionIndex: -1, + log: [], +}); + +const move = (sourceId: string, targetHex: { q: number; r: number }): ResolvedAction => ({ + actionIndex: 0, + actionType: 'Move', + targetHex, + sourceId, + sourceTeam: 'player', + initiative: 10, +}); + +const posOf = (state: CombatState, id: string) => state.combatants.find((c) => c.id === id)!.position; + +describe('battlefield dimensions', () => { + it('confines movement to a custom battlefield rather than the default arena', () => { + // A cramped 4x4 room: a move aimed well outside it must stop at its edge. + const hero = mkCombatant({ id: 'hero', position: at(0, 0) }); + const state = mkState([hero], { dims: { cols: 4, rows: 4 } }); + const { state: after } = resolveAction(state, move('hero', at(15, 0))); + expect(posOf(after, 'hero').q).toBeLessThanOrEqual(3); + }); + + it('still uses the default arena when no battlefield is supplied', () => { + const hero = mkCombatant({ id: 'hero', position: at(0, 0) }); + const { state: after } = resolveAction(mkState([hero]), move('hero', at(15, 0))); + expect(posOf(after, 'hero').q).toBeGreaterThan(3); + }); +}); + +describe('walls block movement', () => { + it('a Move cannot cross a walled border', () => { + const hero = mkCombatant({ id: 'hero', position: at(2, 2) }); + // Seal the hex completely: the hero has nowhere to go. + const walls = Array.from({ length: 6 }, (_, d) => edgeId(at(2, 2), d)); + const state = mkState([hero], { dims: { cols: 8, rows: 8 }, walls }); + const { state: after, log } = resolveAction(state, move('hero', at(6, 2))); + expect(posOf(after, 'hero')).toEqual(at(2, 2)); + expect(log.map((l) => l.text).join(' ')).toContain('cannot move'); + }); + + it('a Move routes around a wall to reach the far side', () => { + const hero = mkCombatant({ id: 'hero', position: at(2, 2) }); + const goal = at(3, 2); + const state = mkState([hero], { + dims: { cols: 8, rows: 8 }, + walls: [edgeId(at(2, 2), 0)], // only the direct border is walled + }); + const { state: after } = resolveAction(state, move('hero', goal)); + expect(posOf(after, 'hero')).toEqual(goal); + }); + + it('a solid hex is not a legal destination', () => { + const hero = mkCombatant({ id: 'hero', position: at(2, 2) }); + const pillar = at(3, 2); + const state = mkState([hero], { dims: { cols: 8, rows: 8 }, solid: [hexKey(pillar)] }); + const { state: after } = resolveAction(state, move('hero', pillar)); + expect(posOf(after, 'hero')).not.toEqual(pillar); + }); +}); + +describe('line of sight gates targeting', () => { + const hero = mkCombatant({ id: 'hero', position: at(2, 2) }); + const foe = mkCombatant({ id: 'foe', team: 'npc', position: at(3, 2) }); + const wall = compileTerrain({ dims: { cols: 8, rows: 8 }, walls: [edgeId(at(2, 2), 0)] }); + + it('a target in range but behind a wall is not targetable', () => { + expect(isTargetInRange(hero, foe, 'Melee')).toBe(true); + expect(isTargetInRange(hero, foe, 'Melee', wall)).toBe(false); + }); + + it('an open door restores the shot; a closed one does not', () => { + const id = String(edgeId(at(2, 2), 0)); + const open = compileTerrain({ dims: { cols: 8, rows: 8 }, doors: { [id]: 'open' } }); + const shut = compileTerrain({ dims: { cols: 8, rows: 8 }, doors: { [id]: 'locked' } }); + expect(isTargetInRange(hero, foe, 'Melee', open)).toBe(true); + expect(isTargetInRange(hero, foe, 'Melee', shut)).toBe(false); + }); + + it('unbounded "Battlefield" range still respects walls', () => { + expect(isTargetInRange(hero, foe, 'Battlefield')).toBe(true); + expect(isTargetInRange(hero, foe, 'Battlefield', wall)).toBe(false); + }); + + it('"Self" is a team check and is never gated by geometry', () => { + const ally = mkCombatant({ id: 'ally', position: at(7, 7) }); + expect(isTargetInRange(hero, ally, 'Self', wall)).toBe(true); + }); +}); + +describe('forced movement respects walls', () => { + // Forced movement walks its OWN step loop rather than going through + // reachableHexes, so it is the one path that would silently keep shoving + // combatants through solid stone if terrain weren't threaded into it. + const SHOVE_NODE = { + id: 'shove-node', + x: 0, + y: 0, + label: 'Shove', + description: '', + isCenter: false, + linkedItem: { + id: 'shove', + type: 'Ability', + name: 'Shove', + description: '', + range: 'Melee', + aoe: 'Single Target', + hitType: 'Auto Hit', + combatUse: true, + // `rows` is the engine's hop count for a shove (see applyEffect's Move Target). + effects: [{ id: 'e1', type: 'Move Target', direction: 'Away From', rows: 3 }], + }, + } as unknown as SkillNode; + + const shove = (): ResolvedAction => ({ + actionIndex: 0, + actionType: 'Use Ability', + actionId: 'shove-node', + sourceId: 'shover', + targetId: 'victim', + sourceTeam: 'player', + initiative: 10, + }); + + const runShove = (battlefield: Battlefield) => { + setActiveCatalog(buildActiveCatalog({ nodes: [SHOVE_NODE], edges: [], worldItems: {} }, 'extend')); + const shover = mkCombatant({ id: 'shover', position: at(1, 2) }); + const victim = mkCombatant({ id: 'victim', team: 'npc', position: at(2, 2) }); + return resolveAction(mkState([shover, victim], battlefield), shove(), () => 0.5); + }; + + afterEach(() => resetActiveCatalog()); + + it('pushes freely across open ground', () => { + const { state } = runShove({ dims: { cols: 8, rows: 8 } }); + expect(posOf(state, 'victim').q).toBeGreaterThan(2); + }); + + it('stops at a wall instead of shoving through solid stone', () => { + const { state } = runShove({ + dims: { cols: 8, rows: 8 }, + walls: [edgeId(at(2, 2), 0)], // wall immediately behind the victim + }); + expect(posOf(state, 'victim')).toEqual(at(2, 2)); + }); + + it('stops at a solid hex', () => { + const { state } = runShove({ dims: { cols: 8, rows: 8 }, solid: [hexKey(at(3, 2))] }); + expect(posOf(state, 'victim')).toEqual(at(2, 2)); + }); +}); diff --git a/src/engine/hex.ts b/src/engine/hex.ts index 66f2908..cf0ea8e 100644 --- a/src/engine/hex.ts +++ b/src/engine/hex.ts @@ -4,17 +4,16 @@ * Coordinates are axial `{q, r}`. All functions here are pure and deterministic; * they back both the engine's positioning rules and the isometric board's layout. */ -import type { HexCoord } from '@/types'; +import type { HexCoord, GridDims, Battlefield, DoorState } from '@/types'; -export interface GridDims { - cols: number; - rows: number; -} +export type { GridDims }; export const hexKey = (c: HexCoord): string => `${c.q},${c.r}`; export const hexEquals = (a: HexCoord, b: HexCoord): boolean => a.q === b.q && a.r === b.r; -/** The six axial neighbor directions (pointy-top). */ +/** The six axial neighbor directions (pointy-top). Ordered so that direction `d` + * and direction `(d + 3) % 6` are exact opposites — the edge-ownership scheme + * below depends on that pairing. */ export const HEX_DIRECTIONS: readonly HexCoord[] = [ { q: 1, r: 0 }, { q: 1, r: -1 }, @@ -24,10 +23,138 @@ export const HEX_DIRECTIONS: readonly HexCoord[] = [ { q: 0, r: 1 }, ]; +/** The direction index from `from` to an adjacent `to`, or -1 if not adjacent. */ +export function dirIndex(from: HexCoord, to: HexCoord): number { + const dq = to.q - from.q; + const dr = to.r - from.r; + for (let i = 0; i < 6; i += 1) { + if (HEX_DIRECTIONS[i].q === dq && HEX_DIRECTIONS[i].r === dr) return i; + } + return -1; +} + export function hexNeighbors(c: HexCoord): HexCoord[] { return HEX_DIRECTIONS.map((d) => ({ q: c.q + d.q, r: c.r + d.r })); } +// --------------------------------------------------------------------------- +// Hex edges — where walls live +// --------------------------------------------------------------------------- + +/** + * The greatest magnitude an axial coordinate may reach and still get a unique + * {@link edgeId}. A battlefield that wide would hold ~1M hexes, so this is far + * beyond any real board; it exists so the packing has a stated contract. + * + * The packed fields are twice this wide, which matters: naming an edge first + * normalizes to the hex that *owns* it, and that can step one hex further out + * than the caller's own coordinate. The headroom means a hex at the very limit + * still resolves without wrapping into a different edge's id. + */ +export const EDGE_COORD_LIMIT = 512; +const EDGE_FIELD_BIAS = EDGE_COORD_LIMIT * 2; + +/** + * A stable integer id for the border between two adjacent hexes. + * + * Each hex **owns** three of its six edges — directions 0, 1, 2 (E, NE, NW) — + * and delegates the other three to the neighbour on that side, which owns the + * same border from its own perspective. Because `HEX_DIRECTIONS` is ordered so + * `opposite(d) === (d + 3) % 6`, normalizing a high direction is just "step to + * the neighbour, then subtract 3". Both hexes therefore agree on one id, which + * is what lets a wall be stored once and block movement in both directions. + * + * The result is a plain number (not a string) because this sits in the innermost + * loop of every pathfinding call, where a `Set` probe beats allocating a + * key string per neighbour. + */ +export function edgeId(c: HexCoord, direction: number): number { + let q = c.q; + let r = c.r; + let side = direction; + if (side >= 3) { + const v = HEX_DIRECTIONS[side]; + q += v.q; + r += v.r; + side -= 3; + } + return ((q + EDGE_FIELD_BIAS) << 14) | ((r + EDGE_FIELD_BIAS) << 3) | side; +} + +/** + * A {@link Battlefield}'s structure compiled into lookups the engine can probe + * in constant time. Built once per resolution by {@link compileTerrain}; the + * wire/persisted form stays plain JSON-safe arrays. + */ +export interface Terrain { + walls: ReadonlySet; + doors: ReadonlyMap; + solid: ReadonlySet; +} + +/** + * Compile a {@link Battlefield}'s JSON arrays into {@link Terrain} lookups. + * Returns `undefined` for a battlefield with no structure at all, so every + * downstream check short-circuits on a plain open arena. + */ +export function compileTerrain(field: Battlefield | undefined): Terrain | undefined { + if (!field) return undefined; + const walls = new Set(); + for (const w of field.walls ?? []) if (Number.isFinite(w)) walls.add(w); + const doors = new Map(); + for (const [k, state] of Object.entries(field.doors ?? {})) { + const id = Number(k); + if (Number.isFinite(id)) doors.set(id, state); + } + const solid = new Set(field.solid ?? []); + if (walls.size === 0 && doors.size === 0 && solid.size === 0) return undefined; + return { walls, doors, solid }; +} + +/** + * Whether the border on `direction` from `hex` stops movement or sight. A wall + * always blocks; a door blocks unless it is open. + */ +export function edgeBlocks(terrain: Terrain | undefined, hex: HexCoord, direction: number): boolean { + if (!terrain) return false; + const id = edgeId(hex, direction); + if (terrain.walls.has(id)) return true; + const door = terrain.doors.get(id); + return door !== undefined && door !== 'open'; +} + +/** Whether a whole hex is impassable terrain (a pillar, a pit, deep water). */ +export function hexIsSolid(terrain: Terrain | undefined, hex: HexCoord): boolean { + return terrain ? terrain.solid.has(hexKey(hex)) : false; +} + +/** + * Whether `a` can see `b` — false when a wall (or a shut door) lies across the + * hex line between them. + * + * Deliberately **permissive**: {@link hexLineDraw}'s rounding is not symmetric, + * so A can have line of sight to B while B does not have it back to A. Rather + * than pick an arbitrary winner we check both directions and grant sight if + * either path is clear — players forgive "they shouldn't have seen me" far more + * readily than "I can't shoot the thing I'm looking at". + */ +export function hasLineOfSight(a: HexCoord, b: HexCoord, terrain: Terrain | undefined): boolean { + if (!terrain || terrain.walls.size + terrain.doors.size === 0) return true; + return clearPath(a, b, terrain) || clearPath(b, a, terrain); +} + +function clearPath(from: HexCoord, to: HexCoord, terrain: Terrain): boolean { + const path = hexLineDraw(from, to); + for (let i = 0; i < path.length - 1; i += 1) { + const d = dirIndex(path[i], path[i + 1]); + // A line that skips a hex can't be checked edge-by-edge; treat it as clear + // here and let the other direction decide. + if (d < 0) continue; + if (edgeBlocks(terrain, path[i], d)) return false; + } + return true; +} + /** Number of single-step moves between two hexes. */ export function hexDistance(a: HexCoord, b: HexCoord): number { const dq = a.q - b.q; @@ -133,12 +260,32 @@ export function gridHexes(dims: GridDims): HexCoord[] { } /** - * Distinct starting hexes for a team, centered horizontally and placed a few rows - * to either side of the midline (players below, enemies above) — leaving an open - * gap between the lines instead of pinning them to the back rows. The GM can drag - * everyone elsewhere during the setup phase. Returns exactly `count` hexes. + * Distinct starting hexes for a team. + * + * With explicit `zones` — which a map-derived battlefield supplies, nominating + * e.g. the two entrances of a ruin — the team simply draws from its own list. + * That is the only workable answer on an irregular map, where the midline rows + * may lie inside a wall or outside the cave entirely. + * + * Without them, the classic geometry applies: centered horizontally, a few rows + * to either side of the midline (players below, enemies above), leaving an open + * gap between the lines rather than pinning them to the back rows. Note this + * reserves up to 8 rows, which is most of a small board — another reason a + * generated interior should nominate its own zones. The GM can drag everyone + * elsewhere during the setup phase. + * + * Returns at most `count` hexes (fewer only when a supplied zone is smaller than + * the team; the caller places any overflow). */ -export function deployHexes(team: 'player' | 'npc', count: number, dims: GridDims): HexCoord[] { +export function deployHexes( + team: 'player' | 'npc', + count: number, + dims: GridDims, + zones?: Battlefield['zones'], +): HexCoord[] { + const zone = zones?.[team]; + if (zone && zone.length > 0) return zone.slice(0, count).map((h) => ({ q: h.q, r: h.r })); + const mid = Math.floor(dims.rows / 2); // Two-row gap around the midline; rows fan outward from there if more are needed. const rows = @@ -164,15 +311,21 @@ export function hexToPixel(c: HexCoord, size: number): { x: number; y: number } } /** - * Open hexes reachable from `from` within `maxSteps`, respecting grid bounds and - * a `blocked` predicate (e.g. hexes occupied by other combatants). Excludes + * Open hexes reachable from `from` within `maxSteps`, respecting grid bounds, a + * `blocked` predicate (e.g. hexes occupied by other combatants), and — when a + * battlefield has structure — walls, shut doors, and solid hexes. Excludes * `from` itself. + * + * `blocked` and `terrain` answer different questions on purpose: a *body* + * blocks a hex, a *wall* blocks a border. Omitting `terrain` gives exactly the + * open-arena behaviour the game has always had. */ export function reachableHexes( from: HexCoord, maxSteps: number, blocked: (c: HexCoord) => boolean, dims: GridDims, + terrain?: Terrain, ): HexCoord[] { const seen = new Set([hexKey(from)]); const out: HexCoord[] = []; @@ -180,9 +333,15 @@ export function reachableHexes( for (let step = 0; step < maxSteps; step += 1) { const next: HexCoord[] = []; for (const c of frontier) { - for (const n of hexNeighbors(c)) { + // Walk directions by index rather than via hexNeighbors: naming the edge + // between two hexes requires the direction, which the neighbour list drops. + for (let d = 0; d < 6; d += 1) { + if (edgeBlocks(terrain, c, d)) continue; + const v = HEX_DIRECTIONS[d]; + const n = { q: c.q + v.q, r: c.r + v.r }; const k = hexKey(n); if (seen.has(k) || !inBounds(n, dims) || blocked(n)) continue; + if (terrain?.solid.has(k)) continue; seen.add(k); out.push(n); next.push(n); @@ -204,10 +363,11 @@ export function closestReachableTo( maxSteps: number, blocked: (c: HexCoord) => boolean, dims: GridDims, + terrain?: Terrain, ): HexCoord { let best = from; let bestD = hexDistance(from, goal); - for (const c of reachableHexes(from, maxSteps, blocked, dims)) { + for (const c of reachableHexes(from, maxSteps, blocked, dims, terrain)) { const d = hexDistance(c, goal); if (d < bestD) { bestD = d; diff --git a/src/engine/hexTerrain.test.ts b/src/engine/hexTerrain.test.ts new file mode 100644 index 0000000..465daa3 --- /dev/null +++ b/src/engine/hexTerrain.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'vitest'; +import { + HEX_DIRECTIONS, + dirIndex, + edgeId, + compileTerrain, + edgeBlocks, + hexIsSolid, + hasLineOfSight, + reachableHexes, + closestReachableTo, + deployHexes, + hexKey, + EDGE_COORD_LIMIT, +} from './hex'; +import type { Battlefield, HexCoord } from '@/types'; + +const DIMS = { cols: 8, rows: 8 }; +const never = () => false; +const at = (q: number, r: number): HexCoord => ({ q, r }); +const step = (c: HexCoord, d: number): HexCoord => ({ + q: c.q + HEX_DIRECTIONS[d].q, + r: c.r + HEX_DIRECTIONS[d].r, +}); + +describe('dirIndex', () => { + it('recovers the direction between adjacent hexes', () => { + const origin = at(3, 3); + for (let d = 0; d < 6; d += 1) { + expect(dirIndex(origin, step(origin, d))).toBe(d); + } + }); + + it('returns -1 for hexes that are not adjacent', () => { + expect(dirIndex(at(0, 0), at(0, 0))).toBe(-1); + expect(dirIndex(at(0, 0), at(3, 0))).toBe(-1); + }); +}); + +describe('edgeId', () => { + it('gives both hexes sharing a border the SAME id', () => { + // This is the whole point of edge ownership: a wall stored once must block + // movement in both directions. + const c = at(2, 3); + for (let d = 0; d < 6; d += 1) { + const n = step(c, d); + expect(edgeId(c, d)).toBe(edgeId(n, (d + 3) % 6)); + } + }); + + it('is injective across a realistic board — no two borders alias', () => { + const ids = new Set(); + let borders = 0; + for (let q = -20; q <= 20; q += 1) { + for (let r = -20; r <= 20; r += 1) { + for (let d = 0; d < 3; d += 1) { + ids.add(edgeId(at(q, r), d)); + borders += 1; + } + } + } + expect(ids.size).toBe(borders); + }); + + it('stays a non-negative safe integer at the documented coordinate limit', () => { + // Naming an edge normalizes to the hex that owns it, which can sit one step + // further out than the caller — the packing must not wrap at the boundary. + const lim = EDGE_COORD_LIMIT; + const corners = [at(lim, lim), at(-lim, -lim), at(lim, -lim), at(-lim, lim)]; + const ids = new Set(); + for (const c of corners) { + for (let d = 0; d < 6; d += 1) { + const id = edgeId(c, d); + expect(Number.isSafeInteger(id)).toBe(true); + expect(id).toBeGreaterThanOrEqual(0); + ids.add(id); + } + } + // Four far-apart hexes share no borders, so all 24 ids must be distinct. + expect(ids.size).toBe(24); + }); +}); + +describe('compileTerrain', () => { + it('returns undefined for an open arena so every check short-circuits', () => { + expect(compileTerrain(undefined)).toBeUndefined(); + expect(compileTerrain({ dims: DIMS })).toBeUndefined(); + expect(compileTerrain({ dims: DIMS, walls: [], solid: [], doors: {} })).toBeUndefined(); + }); + + it('parses JSON-safe door keys back into numeric edge ids', () => { + const id = edgeId(at(1, 1), 0); + const t = compileTerrain({ dims: DIMS, doors: { [String(id)]: 'closed' } }); + expect(t?.doors.get(id)).toBe('closed'); + }); + + it('drops non-finite wall ids rather than poisoning the lookup', () => { + const t = compileTerrain({ dims: DIMS, walls: [NaN, 42, Infinity] } as unknown as Battlefield); + expect([...(t?.walls ?? [])]).toEqual([42]); + }); +}); + +describe('edgeBlocks', () => { + const c = at(2, 2); + const wall = edgeId(c, 0); + + it('blocks from both sides of the wall', () => { + const t = compileTerrain({ dims: DIMS, walls: [wall] }); + expect(edgeBlocks(t, c, 0)).toBe(true); + expect(edgeBlocks(t, step(c, 0), 3)).toBe(true); + }); + + it('treats an open door as passable and a shut one as solid', () => { + const open = compileTerrain({ dims: DIMS, doors: { [String(wall)]: 'open' } }); + expect(edgeBlocks(open, c, 0)).toBe(false); + for (const state of ['closed', 'locked'] as const) { + const t = compileTerrain({ dims: DIMS, doors: { [String(wall)]: state } }); + expect(edgeBlocks(t, c, 0)).toBe(true); + } + }); + + it('never blocks without terrain', () => { + expect(edgeBlocks(undefined, c, 0)).toBe(false); + }); +}); + +describe('reachableHexes with terrain', () => { + it('is unchanged when no terrain is supplied', () => { + const plain = reachableHexes(at(3, 3), 2, never, DIMS); + const withEmpty = reachableHexes(at(3, 3), 2, never, DIMS, compileTerrain({ dims: DIMS })); + expect(withEmpty.map(hexKey).sort()).toEqual(plain.map(hexKey).sort()); + }); + + it('will not step through a wall', () => { + const from = at(3, 3); + const beyond = step(from, 0); + const t = compileTerrain({ dims: DIMS, walls: [edgeId(from, 0)] }); + const keys = reachableHexes(from, 1, never, DIMS, t).map(hexKey); + expect(keys).not.toContain(hexKey(beyond)); + expect(keys).toContain(hexKey(step(from, 1))); + }); + + it('seals a hex in completely when all six borders are walled', () => { + const from = at(3, 3); + const walls = Array.from({ length: 6 }, (_, d) => edgeId(from, d)); + const t = compileTerrain({ dims: DIMS, walls }); + expect(reachableHexes(from, 6, never, DIMS, t)).toEqual([]); + }); + + it('routes around a wall rather than treating it as a dead end', () => { + // Wall only the direct border; the neighbour must still be reachable the long way. + const from = at(3, 3); + const beyond = step(from, 0); + const t = compileTerrain({ dims: DIMS, walls: [edgeId(from, 0)] }); + const keys = reachableHexes(from, 3, never, DIMS, t).map(hexKey); + expect(keys).toContain(hexKey(beyond)); + }); + + it('excludes solid hexes', () => { + const from = at(3, 3); + const pillar = step(from, 0); + const t = compileTerrain({ dims: DIMS, solid: [hexKey(pillar)] }); + expect(reachableHexes(from, 2, never, DIMS, t).map(hexKey)).not.toContain(hexKey(pillar)); + }); + + it('closestReachableTo respects walls', () => { + const from = at(3, 3); + const goal = at(6, 3); + const walls = [edgeId(from, 0), edgeId(from, 1), edgeId(from, 5)]; + const t = compileTerrain({ dims: DIMS, walls }); + const open = closestReachableTo(from, goal, 1, never, DIMS); + const walled = closestReachableTo(from, goal, 1, never, DIMS, t); + expect(hexKey(open)).not.toBe(hexKey(from)); + expect(hexKey(walled)).toBe(hexKey(from)); // every step toward the goal is walled off + }); +}); + +describe('hasLineOfSight', () => { + it('is true everywhere on an open field', () => { + expect(hasLineOfSight(at(0, 0), at(5, 2), undefined)).toBe(true); + expect(hasLineOfSight(at(0, 0), at(5, 2), compileTerrain({ dims: DIMS }))).toBe(true); + }); + + it('is blocked by a wall directly between two adjacent hexes', () => { + const a = at(2, 2); + const t = compileTerrain({ dims: DIMS, walls: [edgeId(a, 0)] }); + expect(hasLineOfSight(a, step(a, 0), t)).toBe(false); + }); + + it('gives the same answer in both directions', () => { + // hexLineDraw's rounding is not symmetric, so LOS is evaluated both ways and + // OR-ed; the observable behaviour must not depend on who is looking. + const a = at(1, 1); + const b = at(5, 4); + const walls = [edgeId(at(3, 2), 0), edgeId(at(3, 2), 1), edgeId(at(2, 3), 0)]; + const t = compileTerrain({ dims: DIMS, walls }); + expect(hasLineOfSight(a, b, t)).toBe(hasLineOfSight(b, a, t)); + }); + + it('sees through an open door but not a shut one', () => { + const a = at(2, 2); + const b = step(a, 0); + const id = edgeId(a, 0); + expect(hasLineOfSight(a, b, compileTerrain({ dims: DIMS, doors: { [String(id)]: 'open' } }))).toBe(true); + expect(hasLineOfSight(a, b, compileTerrain({ dims: DIMS, doors: { [String(id)]: 'closed' } }))).toBe(false); + }); +}); + +describe('hexIsSolid', () => { + it('reads the solid set, and is false without terrain', () => { + const t = compileTerrain({ dims: DIMS, solid: ['1,1'] }); + expect(hexIsSolid(t, at(1, 1))).toBe(true); + expect(hexIsSolid(t, at(1, 2))).toBe(false); + expect(hexIsSolid(undefined, at(1, 1))).toBe(false); + }); +}); + +describe('deployHexes with zones', () => { + const zones = { + player: [at(0, 7), at(1, 7)], + npc: [at(0, 0), at(1, 0), at(2, 0)], + }; + + it('draws from the supplied zone instead of the midline rows', () => { + expect(deployHexes('player', 2, DIMS, zones)).toEqual(zones.player); + expect(deployHexes('npc', 2, DIMS, zones)).toEqual(zones.npc.slice(0, 2)); + }); + + it('returns a short list rather than inventing hexes outside the zone', () => { + // The caller places the overflow; silently spilling into walls would be worse. + expect(deployHexes('player', 5, DIMS, zones)).toHaveLength(2); + }); + + it('falls back to the classic midline rows when a zone is absent or empty', () => { + const classic = deployHexes('player', 3, DIMS); + expect(deployHexes('player', 3, DIMS, undefined)).toEqual(classic); + expect(deployHexes('player', 3, DIMS, { player: [], npc: [] })).toEqual(classic); + }); + + it('copies zone hexes so a caller cannot mutate the battlefield', () => { + const out = deployHexes('npc', 1, DIMS, zones); + out[0].q = 99; + expect(zones.npc[0].q).toBe(0); + }); +}); diff --git a/src/engine/index.ts b/src/engine/index.ts index 35e01bc..e3d4519 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -73,6 +73,18 @@ export { type GridDims, } from './hex'; +// Battlefield structure — walls live on the borders between hexes, not on hexes +export { + dirIndex, + edgeId, + compileTerrain, + edgeBlocks, + hexIsSolid, + hasLineOfSight, + EDGE_COORD_LIMIT, + type Terrain, +} from './hex'; + // Combat export { createCombatant, diff --git a/src/lib/combat.test.ts b/src/lib/combat.test.ts index 6213ad4..d045158 100644 --- a/src/lib/combat.test.ts +++ b/src/lib/combat.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { normalizeCombatState } from './combat'; +import { normalizeCombatState, normalizeBattlefield } from './combat'; describe('normalizeCombatState', () => { it('defaults a missing combatants array so player selectors cannot crash', () => { @@ -47,4 +47,62 @@ describe('normalizeCombatState', () => { expect(c.combatants).toHaveLength(1); expect((c.combatants[0] as { id?: string }).id).toBe('x'); }); + + it('carries a battlefield through, normalized', () => { + const c = normalizeCombatState({ battlefield: { dims: { cols: 10, rows: 8 } } } as never); + expect(c.battlefield?.dims).toEqual({ cols: 10, rows: 8 }); + }); + + it('leaves battlefield absent for a plain-arena snapshot', () => { + expect(normalizeCombatState({ isActive: true }).battlefield).toBeUndefined(); + }); +}); + +describe('normalizeBattlefield', () => { + it('clamps absurd dimensions instead of allocating until the tab dies', () => { + const b = normalizeBattlefield({ dims: { cols: 1e9, rows: -5 } }); + expect(b?.dims.cols).toBe(200); + expect(b?.dims.rows).toBe(1); + }); + + it('coerces missing or non-numeric dimensions to a usable minimum', () => { + expect(normalizeBattlefield({}) ?.dims).toEqual({ cols: 1, rows: 1 }); + expect(normalizeBattlefield({ dims: { cols: 'x', rows: NaN } } as never)?.dims).toEqual({ + cols: 1, + rows: 1, + }); + }); + + it('drops wall ids that are not safe integers', () => { + const b = normalizeBattlefield({ dims: { cols: 4, rows: 4 }, walls: [1, NaN, 'x', 2.5, 7] } as never); + expect(b?.walls).toEqual([1, 7]); + }); + + it('keeps only recognized door states, keyed by a numeric string', () => { + const b = normalizeBattlefield({ + dims: { cols: 4, rows: 4 }, + doors: { '10': 'open', '11': 'ajar', notANumber: 'closed', '12': 'locked' }, + } as never); + expect(b?.doors).toEqual({ '10': 'open', '12': 'locked' }); + }); + + it('drops zone hexes with garbage or out-of-range coordinates', () => { + const b = normalizeBattlefield({ + dims: { cols: 4, rows: 4 }, + zones: { player: [{ q: 1, r: 1 }, { q: NaN, r: 0 }, { q: 99999, r: 0 }, null], npc: 'nope' }, + } as never); + expect(b?.zones?.player).toEqual([{ q: 1, r: 1 }]); + expect(b?.zones?.npc).toEqual([]); + }); + + it('omits empty structure so an open arena short-circuits every check', () => { + const b = normalizeBattlefield({ dims: { cols: 4, rows: 4 }, walls: [], solid: [], doors: {} }); + expect(b).toEqual({ dims: { cols: 4, rows: 4 } }); + }); + + it('returns undefined for a non-object, and never throws', () => { + expect(normalizeBattlefield(null)).toBeUndefined(); + expect(normalizeBattlefield([1, 2])).toBeUndefined(); + expect(() => normalizeBattlefield('nonsense')).not.toThrow(); + }); }); diff --git a/src/lib/combat.ts b/src/lib/combat.ts index 5e9b8f8..d5cb7a4 100644 --- a/src/lib/combat.ts +++ b/src/lib/combat.ts @@ -1,7 +1,85 @@ -import type { CombatPhase, CombatState } from '@/types'; +import type { Battlefield, CombatPhase, CombatState, DoorState, HexCoord } from '@/types'; +import { EDGE_COORD_LIMIT } from '@/engine'; const PHASES: CombatPhase[] = ['setup', 'declare', 'resolving', 'between', 'ended']; +/** Largest arena we will render or path over. A board bigger than this would + * hang the client long before it was playable, so a wire value claiming + * 100000×100000 is clamped rather than trusted. */ +const MAX_GRID_SIDE = 200; +/** Caps on structure, generous for any hand-authored or generated interior. */ +const MAX_WALLS = 20_000; +const MAX_SOLID = 20_000; +const MAX_ZONE = 200; + +const DOOR_STATES: DoorState[] = ['open', 'closed', 'locked']; + +const clampSide = (v: unknown, fallback: number): number => + typeof v === 'number' && Number.isFinite(v) + ? Math.min(MAX_GRID_SIDE, Math.max(1, Math.floor(v))) + : fallback; + +function cleanHexes(v: unknown): HexCoord[] { + if (!Array.isArray(v)) return []; + const out: HexCoord[] = []; + for (const h of v.slice(0, MAX_ZONE)) { + if (!h || typeof h !== 'object') continue; + const { q, r } = h as HexCoord; + if (!Number.isFinite(q) || !Number.isFinite(r)) continue; + if (Math.abs(q) > EDGE_COORD_LIMIT || Math.abs(r) > EDGE_COORD_LIMIT) continue; + out.push({ q: Math.trunc(q), r: Math.trunc(r) }); + } + return out; +} + +/** + * Coerce an untrusted {@link Battlefield} into something safe to path over. + * + * The danger here isn't malice so much as arithmetic: `dims` feeds `gridHexes` + * and the board renderer, so an absurd value would allocate until the tab dies. + * Wall ids that don't correspond to any real border are harmless — they simply + * never match — so they only need to be finite integers. + */ +export function normalizeBattlefield(raw: unknown): Battlefield | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const r = raw as Partial; + const dims = { + cols: clampSide(r.dims?.cols, 1), + rows: clampSide(r.dims?.rows, 1), + }; + + const walls = Array.isArray(r.walls) + ? r.walls.slice(0, MAX_WALLS).filter((w): w is number => Number.isSafeInteger(w)) + : undefined; + + let doors: Record | undefined; + if (r.doors && typeof r.doors === 'object' && !Array.isArray(r.doors)) { + doors = {}; + for (const [k, v] of Object.entries(r.doors).slice(0, MAX_WALLS)) { + if (Number.isSafeInteger(Number(k)) && DOOR_STATES.includes(v as DoorState)) { + doors[k] = v as DoorState; + } + } + } + + const solid = Array.isArray(r.solid) + ? r.solid.slice(0, MAX_SOLID).filter((s): s is string => typeof s === 'string') + : undefined; + + const zones = r.zones + ? { player: cleanHexes(r.zones.player), npc: cleanHexes(r.zones.npc) } + : undefined; + + return { + dims, + ...(walls?.length ? { walls } : {}), + ...(doors && Object.keys(doors).length ? { doors } : {}), + ...(solid?.length ? { solid } : {}), + ...(zones ? { zones } : {}), + ...(typeof r.label === 'string' && r.label.trim() ? { label: r.label.slice(0, 120) } : {}), + }; +} + /** * Coerce an untrusted combat snapshot (received from the GM over the wire) into a * structurally-valid {@link CombatState}. Players apply host snapshots wholesale, @@ -19,6 +97,7 @@ export function normalizeCombatState(raw: unknown): CombatState { isActive: Boolean(r.isActive), phase: PHASES.includes(r.phase as CombatPhase) ? (r.phase as CombatPhase) : 'declare', round: Number.isFinite(r.round as number) ? (r.round as number) : 1, + ...(r.battlefield ? { battlefield: normalizeBattlefield(r.battlefield) } : {}), // Keep only real combatant objects — a null/garbage entry from the wire would // crash every selector that reads `.team`/`.peerId`/`.position` off each one. combatants: Array.isArray(r.combatants) diff --git a/src/types/combat.ts b/src/types/combat.ts index 61e66b8..88da4b2 100644 --- a/src/types/combat.ts +++ b/src/types/combat.ts @@ -8,6 +8,44 @@ export interface HexCoord { r: number; } +/** Battlefield extent, in offset-rectangular (odd-r) columns and rows. */ +export interface GridDims { + cols: number; + rows: number; +} + +/** State of a door sitting on the border between two hexes. */ +export type DoorState = 'open' | 'closed' | 'locked'; + +/** + * The physical space a fight happens in — the battlefield's size and structure. + * + * **Walls are edges, not cells.** A wall sits on the *border between* two hexes + * rather than consuming a hex, so a walled interior costs zero standable ground + * (a 7-room layout is ~100 edges instead of ~93 of 252 hexes). Edge ids are the + * canonical integers produced by `edgeId` (`src/engine/hex.ts`); a door is a + * state on an edge, so it renders as a gap in the wall for free. + * + * Every field is JSON-safe: this crosses the wire inside {@link CombatState} and + * is persisted, so no `Set`/`Map` may appear here. The engine compiles it into + * fast lookups once per resolution via `compileTerrain`. + * + * Absent entirely ⇒ the plain rectangular arena the game has always used. + */ +export interface Battlefield { + dims: GridDims; + /** Impassable borders, as canonical `edgeId` integers. */ + walls?: number[]; + /** Doors keyed by `String(edgeId)` (JSON object keys are strings). */ + doors?: Record; + /** Whole impassable hexes — pillars, pits, deep water — keyed by `hexKey`. */ + solid?: string[]; + /** Where each team deploys. Absent ⇒ the default midline rows. */ + zones?: { player: HexCoord[]; npc: HexCoord[] }; + /** Human-readable origin, e.g. "The Salt Lantern — common room". */ + label?: string; +} + export interface Combatant { id: string; /** PeerJS connection id for player combatants; null for GM-controlled NPCs. */ @@ -105,6 +143,9 @@ export interface CombatState { isActive: boolean; phase: CombatPhase; round: number; + /** The space this fight happens in. Absent ⇒ the default open arena + * (`BATTLE_GRID`), which is what every fight not staged from a map gets. */ + battlefield?: Battlefield; combatants: Combatant[]; /** combatantId -> declared actions for the round. */ declaredActions: Record; From 083ed1715e0d47abb99013a2a5ac895d7894a0b7 Mon Sep 17 00:00:00 2001 From: Justrada Date: Wed, 29 Jul 2026 01:08:34 -0400 Subject: [PATCH 2/4] Make the battlefield reachable, and fix what that exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arena type shipped in 486af64, but nothing in the app could actually create one — `startCombat` took only combatants and `placeCombatants` hardcoded BATTLE_GRID. This wires it through and fixes the latent bugs that surfaced once a fight could happen somewhere other than the default rectangle. startCombat(combatants, battlefield?, groupId?) Both new arguments are optional, so every existing caller compiles unchanged and all 358 prior tests pass untouched. Fixed along the way, each independently real: - HexBoard's `layout` memo read BATTLE_GRID with an EMPTY dependency array, so the board would have silently kept rendering whatever geometry it computed first. Invisible until an arena changes size — and then baffling. - The reachable-tile highlight called `reachableHexes` with no terrain, so the board would have offered the player tiles through walls that the engine then refuses to move to. The action menu had the same gap. A map that lies to the player is worse than no map. - Occupancy was three different rules: the engine tested `currentHP > 0` (so a mover walked *onto* an unconscious body), while the store and board tested `!isDead`. The board's hex->combatant map is last-writer-wins, so the stacked combatant simply vanished. Now one exported `OCCUPIES` predicate at all four sites: a downed body blocks, a corpse doesn't. - The engine mixed `Math.random()` into every log id, which made "combat is deterministic given a seed" quietly untrue — a poor foundation for a sold map whose whole value rests on reproducibility. Ids are now derived from round + counter, and a replayed round is byte-identical. - Board scale was shrink-only, so a 10x8 tavern would render as a postage stamp in the middle of a wide screen. It may now magnify to 2.5x. Also fixes a live multiplayer bug found while tracing the snapshot gate: the GM never sent `hello`, so the handshake was one-sided. A GM page-reload restarts the outbound sequence counter at 0 while a player still holds a high watermark, and every subsequent snapshot is dropped silently, forever. The GM now greets joiners and the player re-baselines on it. docs/MAPS.md records the full map architecture — chosen after a 14-agent council (3 independent architectures, 6 adversarial judges, synthesis) plus deep research into procedural generation and the VTT map market. Notably the judges verified against this working tree and confirmed the edge-wall arena model over all three proposed replacements. Co-Authored-By: Claude Opus 4.8 --- docs/MAPS.md | 339 ++++++++++++++++++++++++++ docs/RULES.md | 21 +- src/engine/combat.ts | 29 ++- src/engine/combatTerrain.test.ts | 20 +- src/engine/index.ts | 2 + src/index.css | 4 + src/screens/table/combat/HexBoard.tsx | 36 ++- src/store/battlefieldStaging.test.ts | 142 +++++++++++ src/store/combatStore.ts | 43 +++- src/store/contracts.ts | 6 +- src/store/sessionStore.ts | 16 +- src/types/combat.ts | 7 + 12 files changed, 637 insertions(+), 28 deletions(-) create mode 100644 docs/MAPS.md create mode 100644 src/store/battlefieldStaging.test.ts diff --git a/docs/MAPS.md b/docs/MAPS.md new file mode 100644 index 0000000..e77cc92 --- /dev/null +++ b/docs/MAPS.md @@ -0,0 +1,339 @@ +# The Atlas — Maps, Worlds & the Places You Fight In + +> **The vision.** Creators build and sell **maps** the way they already build and +> sell systems. A map ranges from a single island to a whole globe, and you can +> zoom from the full world down to one room in one building — with basements, +> upper floors, and caves. When a fight starts, the battlefield **is** the place +> the party is standing in. Parties can split up and explore separately. + +This doc is the canonical map design, the way [RULES.md](RULES.md) is the +canonical rules. It records **what was decided and why**, so the reasoning +survives longer than any one implementation. + +Status tags, matching RULES.md: **✅ shipped** · **🔧 designed, not built** · +**💭 deliberately deferred**. + +--- + +## 1. The one-sentence architecture + +> **The world is a seed. The battlefield is compiled data.** + +Two layers, one hard one-way boundary, because the two halves of this feature +have *opposite* constraints. + +| | **World layer** | **Battle layer** | +|---|---|---| +| What it is | An `Atlas`: a seed + tuning + the edits a human made | A `Battlefield`: walls, doors, solid hexes, deploy zones | +| Size | 600 bytes – 30 KB | ~30 bytes open ground, ~1.7 KB a seven-room tavern | +| Where it lives | Persisted, sold, shared | Inside the `CombatState` snapshot, like everything else | +| How it's produced | Generated lazily from `f(seed, params, path)` | `compileBattlefield(spec, place)` — a pure function | +| Status | 🔧 | ✅ shipped | + +The atlas does **not** point at a battlefield. It **compiles** one. That single +choice is what lets the map ship with **zero new combat concepts**: `HexCoord` +never grows a Z axis, `CombatState` gains no geometry field it doesn't already +have, and a map-derived fight travels inside the `combat_start` message that +already exists. + +### Why a seed, and not a map file + +Every hard constraint in this app is a constraint on **bytes**: + +- Distribution is a **clipboard paste** into a `