From a3e4ef4b046d12b1db1d1bca6382852106fa12cf Mon Sep 17 00:00:00 2001 From: Vannon Date: Thu, 26 Mar 2026 18:43:01 +0100 Subject: [PATCH 1/4] refactor(sim): move runtime helpers into canonical sim runtime layer --- src/game/runtime/infraRuntime.js | 18 +-- src/game/runtime/orderNavigation.js | 70 +------- src/game/runtime/processActiveOrderRuntime.js | 149 +----------------- src/game/runtime/stateCounts.js | 38 +---- src/game/sim/reducer/core.js | 6 +- src/game/sim/reducer/winConditions.js | 2 +- src/game/sim/runtime/infraRuntime.js | 17 ++ src/game/sim/runtime/orderNavigation.js | 69 ++++++++ .../sim/runtime/processActiveOrderRuntime.js | 148 +++++++++++++++++ src/game/sim/runtime/stateCounts.js | 37 +++++ tests/test-active-order-runtime.mjs | 2 +- 11 files changed, 280 insertions(+), 276 deletions(-) create mode 100644 src/game/sim/runtime/infraRuntime.js create mode 100644 src/game/sim/runtime/orderNavigation.js create mode 100644 src/game/sim/runtime/processActiveOrderRuntime.js create mode 100644 src/game/sim/runtime/stateCounts.js diff --git a/src/game/runtime/infraRuntime.js b/src/game/runtime/infraRuntime.js index 60cd2f6..cdd0ada 100644 --- a/src/game/runtime/infraRuntime.js +++ b/src/game/runtime/infraRuntime.js @@ -1,17 +1 @@ -import { ZONE_ROLE } from "../contracts/ids.js"; -import { cloneTypedArray } from "../sim/shared.js"; -import { hasAdjacentRoleTile4, isRoleMarked } from "../sim/grid/index.js"; - -export function getInfraCandidateMask(world, size) { - if (world?.infraCandidateMask && ArrayBuffer.isView(world.infraCandidateMask)) { - return cloneTypedArray(world.infraCandidateMask); - } - return new Uint8Array(size); -} - -export function touchesCommittedInfraAnchor(world, idx, w, h) { - return isRoleMarked(world?.zoneRole, idx, ZONE_ROLE.CORE) - || isRoleMarked(world?.zoneRole, idx, ZONE_ROLE.DNA) - || isRoleMarked(world?.zoneRole, idx, ZONE_ROLE.INFRA) - || hasAdjacentRoleTile4(world?.zoneRole, idx, w, h, [ZONE_ROLE.CORE, ZONE_ROLE.DNA, ZONE_ROLE.INFRA]); -} +export { getInfraCandidateMask, touchesCommittedInfraAnchor } from "../sim/runtime/infraRuntime.js"; diff --git a/src/game/runtime/orderNavigation.js b/src/game/runtime/orderNavigation.js index ade43ca..11b1088 100644 --- a/src/game/runtime/orderNavigation.js +++ b/src/game/runtime/orderNavigation.js @@ -1,69 +1 @@ -export function findNextStepBfs4(world, fromIdx, targetIdx, w, h) { - if (fromIdx === targetIdx) return fromIdx; - const total = w * h; - if (fromIdx < 0 || targetIdx < 0 || fromIdx >= total || targetIdx >= total) return -1; - const alive = world?.alive; - if (!alive || !ArrayBuffer.isView(alive)) return -1; - - const prev = new Int32Array(total); - prev.fill(-1); - const seen = new Uint8Array(total); - const queue = new Int32Array(total); - let qh = 0; - let qt = 0; - queue[qt++] = fromIdx; - seen[fromIdx] = 1; - - while (qh < qt) { - const idx = queue[qh++]; - if (idx === targetIdx) break; - const x = idx % w; - const y = (idx / w) | 0; - const candidates = [ - [x - 1, y], - [x + 1, y], - [x, y - 1], - [x, y + 1], - ]; - for (const [nx, ny] of candidates) { - if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; - const nIdx = ny * w + nx; - if (seen[nIdx]) continue; - if (nIdx !== targetIdx && (Number(alive[nIdx] || 0) | 0) === 1) continue; - seen[nIdx] = 1; - prev[nIdx] = idx; - queue[qt++] = nIdx; - } - } - - if (!seen[targetIdx]) return -1; - let step = targetIdx; - while (prev[step] !== -1 && prev[step] !== fromIdx) { - step = prev[step]; - } - return prev[step] === fromIdx ? step : targetIdx; -} - -export function moveEntityTile(world, fromIdx, toIdx) { - if (fromIdx === toIdx) return; - const scalarKeys = ["E", "reserve", "link", "lineageId", "hue", "age", "born", "died", "W", "clusterField", "superId"]; - for (const key of scalarKeys) { - const arr = world?.[key]; - if (!arr || !ArrayBuffer.isView(arr)) continue; - arr[toIdx] = arr[fromIdx]; - arr[fromIdx] = 0; - } - if (world?.alive && ArrayBuffer.isView(world.alive)) { - world.alive[toIdx] = 1; - world.alive[fromIdx] = 0; - } - const trait = world?.trait; - if (trait && ArrayBuffer.isView(trait)) { - const fromOff = fromIdx * 7; - const toOff = toIdx * 7; - for (let i = 0; i < 7; i++) { - trait[toOff + i] = trait[fromOff + i]; - trait[fromOff + i] = 0; - } - } -} +export { findNextStepBfs4, moveEntityTile } from "../sim/runtime/orderNavigation.js"; diff --git a/src/game/runtime/processActiveOrderRuntime.js b/src/game/runtime/processActiveOrderRuntime.js index a3c9a0d..29b1711 100644 --- a/src/game/runtime/processActiveOrderRuntime.js +++ b/src/game/runtime/processActiveOrderRuntime.js @@ -1,148 +1 @@ -import { createEmptyActiveOrder, HARVEST_TICKS } from "../sim/commands/orderCommands.js"; -import { findNextStepBfs4, moveEntityTile } from "./orderNavigation.js"; - -export function processActiveOrderRuntime({ - worldMutable, - preStepAlive, - simOut, - meta, - ticksPerSecond = 24, - harvestTicks = HARVEST_TICKS, -}) { - const activeOrder = simOut.activeOrder; - if (!activeOrder?.active) return { worldMutable, simOut }; - - const w = Number(worldMutable?.w || meta?.gridW || 0) | 0; - const h = Number(worldMutable?.h || meta?.gridH || 0) | 0; - const fromX = Number(activeOrder.fromX) | 0; - const fromY = Number(activeOrder.fromY) | 0; - const targetX = Number(activeOrder.targetX) | 0; - const targetY = Number(activeOrder.targetY) | 0; - const targetIdx = targetY * w + targetX; - const playerLineageId = Number(meta?.playerLineageId || 1) | 0; - let unitIdx = Number(simOut.selectedUnit ?? -1) | 0; - if (unitIdx < 0 || unitIdx >= w * h || (Number(worldMutable.alive?.[unitIdx] || 0) | 0) !== 1) { - unitIdx = fromY * w + fromX; - } - const validUnit = - unitIdx >= 0 && - unitIdx < w * h && - (Number(worldMutable.alive?.[unitIdx] || 0) | 0) === 1 && - (Number(worldMutable.lineageId?.[unitIdx] || 0) | 0) === playerLineageId; - - if (!validUnit || targetX < 0 || targetY < 0 || targetX >= w || targetY >= h) { - simOut.unitOrder = { active: false, fromX: -1, fromY: -1, targetX: -1, targetY: -1 }; - simOut.activeOrder = createEmptyActiveOrder(); - simOut.selectedUnit = -1; - simOut.lastAutoAction = "ORDER_ABORTED"; - return { worldMutable, simOut }; - } - - if (unitIdx === targetIdx) { - const maxProgress = Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0); - const nextProgress = Math.min(maxProgress, (Number(activeOrder.progress || 0) | 0) + 1); - if (nextProgress < maxProgress) { - simOut.activeOrder = { - ...activeOrder, - active: true, - type: "HARVEST", - fromX: unitIdx % w, - fromY: (unitIdx / w) | 0, - targetX, - targetY, - progress: nextProgress, - maxProgress, - }; - simOut.unitOrder = { active: true, fromX: unitIdx % w, fromY: (unitIdx / w) | 0, targetX, targetY }; - simOut.selectedUnit = unitIdx; - simOut.lastAutoAction = `HARVEST_PROGRESS:${nextProgress}/${maxProgress}`; - } else { - simOut.playerDNA = Number(simOut.playerDNA || 0) + 1; - simOut.totalHarvested = Number(simOut.totalHarvested || 0) + 1; - simOut.unitOrder = { active: false, fromX: -1, fromY: -1, targetX: -1, targetY: -1 }; - simOut.activeOrder = createEmptyActiveOrder(); - simOut.selectedUnit = unitIdx; - simOut.lastAutoAction = `HARVEST_AUTO:${targetX},${targetY}`; - } - return { worldMutable, simOut }; - } - - const travelTicks = Math.max(1, ticksPerSecond | 0); - const travelProgress = (Number(activeOrder.progress || 0) | 0) + 1; - if (travelProgress < travelTicks) { - simOut.unitOrder = { - active: true, - fromX: unitIdx % w, - fromY: (unitIdx / w) | 0, - targetX, - targetY, - }; - simOut.activeOrder = { - ...activeOrder, - active: true, - type: "HARVEST", - fromX: unitIdx % w, - fromY: (unitIdx / w) | 0, - targetX, - targetY, - progress: travelProgress, - maxProgress: Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0), - }; - simOut.selectedUnit = unitIdx; - simOut.lastAutoAction = `MOVE_WAIT:${travelProgress}/${travelTicks}`; - return { worldMutable, simOut }; - } - - const navigationWorld = preStepAlive ? { ...worldMutable, alive: preStepAlive } : worldMutable; - const nextIdx = findNextStepBfs4(navigationWorld, unitIdx, targetIdx, w, h); - const occupiedAtTickStart = nextIdx >= 0 && (Number(preStepAlive?.[nextIdx] || 0) | 0) === 1; - const hardBlocked = nextIdx < 0 || (occupiedAtTickStart && nextIdx !== targetIdx); - if (hardBlocked) { - simOut.unitOrder = { - active: true, - fromX: unitIdx % w, - fromY: (unitIdx / w) | 0, - targetX, - targetY, - }; - simOut.activeOrder = { - ...activeOrder, - active: true, - type: "HARVEST", - fromX: unitIdx % w, - fromY: (unitIdx / w) | 0, - targetX, - targetY, - progress: 0, - maxProgress: Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0), - }; - simOut.selectedUnit = unitIdx; - simOut.lastAutoAction = "ORDER_WAIT_BLOCKED"; - return { worldMutable, simOut }; - } - - moveEntityTile(worldMutable, unitIdx, nextIdx); - const nx = nextIdx % w; - const ny = (nextIdx / w) | 0; - simOut.selectedUnit = nextIdx; - simOut.unitOrder = { - active: true, - fromX: nx, - fromY: ny, - targetX, - targetY, - }; - simOut.activeOrder = { - ...activeOrder, - active: true, - type: "HARVEST", - fromX: nx, - fromY: ny, - targetX, - targetY, - progress: 0, - maxProgress: Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0), - }; - simOut.lastAutoAction = `MOVE_STEP:${nx},${ny}`; - return { worldMutable, simOut }; -} +export { processActiveOrderRuntime } from "../sim/runtime/processActiveOrderRuntime.js"; diff --git a/src/game/runtime/stateCounts.js b/src/game/runtime/stateCounts.js index 36fbffa..ad034df 100644 --- a/src/game/runtime/stateCounts.js +++ b/src/game/runtime/stateCounts.js @@ -1,37 +1 @@ -export function countAlivePlayerRoleCells(world, playerLineageId, roleId) { - const zoneRole = world?.zoneRole; - const alive = world?.alive; - const lineageId = world?.lineageId; - if (!zoneRole || !alive || !lineageId) return 0; - let count = 0; - for (let i = 0; i < zoneRole.length; i++) { - if ((Number(zoneRole[i]) | 0) !== (roleId | 0)) continue; - if ((Number(alive[i]) | 0) !== 1) continue; - if ((Number(lineageId[i]) | 0) !== (playerLineageId | 0)) continue; - count++; - } - return count; -} - -export function countAlivePlayerMaskedCells(mask, world, playerLineageId) { - const alive = world?.alive; - const lineageId = world?.lineageId; - if (!mask || !alive || !lineageId) return 0; - let count = 0; - for (let i = 0; i < mask.length; i++) { - if ((Number(mask[i]) | 0) !== 1) continue; - if ((Number(alive[i]) | 0) !== 1) continue; - if ((Number(lineageId[i]) | 0) !== (playerLineageId | 0)) continue; - count++; - } - return count; -} - -export function countMaskOnes(mask) { - if (!mask || !ArrayBuffer.isView(mask)) return 0; - let count = 0; - for (let i = 0; i < mask.length; i++) { - if ((Number(mask[i]) | 0) === 1) count++; - } - return count; -} +export { countAlivePlayerRoleCells, countAlivePlayerMaskedCells, countMaskOnes } from "../sim/runtime/stateCounts.js"; diff --git a/src/game/sim/reducer/core.js b/src/game/sim/reducer/core.js index cefba24..2abecad 100644 --- a/src/game/sim/reducer/core.js +++ b/src/game/sim/reducer/core.js @@ -58,11 +58,11 @@ import { derivePatternBonuses, derivePatternCatalog } from "../patterns.js"; import { getInfraCandidateMask, touchesCommittedInfraAnchor, -} from "../../runtime/infraRuntime.js"; +} from "../runtime/infraRuntime.js"; import { countAlivePlayerMaskedCells, countAlivePlayerRoleCells, -} from "../../runtime/stateCounts.js"; +} from "../runtime/stateCounts.js"; import { collectMaskIndices, hasAdjacentMarkedTile, @@ -79,7 +79,7 @@ import { HARVEST_TICKS, parseWorkerEntityId, } from "../commands/orderCommands.js"; -import { processActiveOrderRuntime } from "../../runtime/processActiveOrderRuntime.js"; +import { processActiveOrderRuntime } from "../runtime/processActiveOrderRuntime.js"; import { reduceEconomyActions } from "./economy.js"; import { reduceRunActions } from "./run.js"; import { reduceZoneActions } from "./zones.js"; diff --git a/src/game/sim/reducer/winConditions.js b/src/game/sim/reducer/winConditions.js index 1590adf..80bec10 100644 --- a/src/game/sim/reducer/winConditions.js +++ b/src/game/sim/reducer/winConditions.js @@ -6,7 +6,7 @@ import { WIN_MODE, deriveGoalCode, } from "../../contracts/ids.js"; -import { countAlivePlayerRoleCells, countMaskOnes } from "../../runtime/stateCounts.js"; +import { countAlivePlayerRoleCells, countMaskOnes } from "../runtime/stateCounts.js"; import { deriveGoalCodeWithPresetBias } from "./progression.js"; function deriveDominantTopology(cellPatternCounts) { diff --git a/src/game/sim/runtime/infraRuntime.js b/src/game/sim/runtime/infraRuntime.js new file mode 100644 index 0000000..38eb9ca --- /dev/null +++ b/src/game/sim/runtime/infraRuntime.js @@ -0,0 +1,17 @@ +import { ZONE_ROLE } from "../../contracts/ids.js"; +import { cloneTypedArray } from "../shared.js"; +import { hasAdjacentRoleTile4, isRoleMarked } from "../grid/index.js"; + +export function getInfraCandidateMask(world, size) { + if (world?.infraCandidateMask && ArrayBuffer.isView(world.infraCandidateMask)) { + return cloneTypedArray(world.infraCandidateMask); + } + return new Uint8Array(size); +} + +export function touchesCommittedInfraAnchor(world, idx, w, h) { + return isRoleMarked(world?.zoneRole, idx, ZONE_ROLE.CORE) + || isRoleMarked(world?.zoneRole, idx, ZONE_ROLE.DNA) + || isRoleMarked(world?.zoneRole, idx, ZONE_ROLE.INFRA) + || hasAdjacentRoleTile4(world?.zoneRole, idx, w, h, [ZONE_ROLE.CORE, ZONE_ROLE.DNA, ZONE_ROLE.INFRA]); +} diff --git a/src/game/sim/runtime/orderNavigation.js b/src/game/sim/runtime/orderNavigation.js new file mode 100644 index 0000000..ade43ca --- /dev/null +++ b/src/game/sim/runtime/orderNavigation.js @@ -0,0 +1,69 @@ +export function findNextStepBfs4(world, fromIdx, targetIdx, w, h) { + if (fromIdx === targetIdx) return fromIdx; + const total = w * h; + if (fromIdx < 0 || targetIdx < 0 || fromIdx >= total || targetIdx >= total) return -1; + const alive = world?.alive; + if (!alive || !ArrayBuffer.isView(alive)) return -1; + + const prev = new Int32Array(total); + prev.fill(-1); + const seen = new Uint8Array(total); + const queue = new Int32Array(total); + let qh = 0; + let qt = 0; + queue[qt++] = fromIdx; + seen[fromIdx] = 1; + + while (qh < qt) { + const idx = queue[qh++]; + if (idx === targetIdx) break; + const x = idx % w; + const y = (idx / w) | 0; + const candidates = [ + [x - 1, y], + [x + 1, y], + [x, y - 1], + [x, y + 1], + ]; + for (const [nx, ny] of candidates) { + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + const nIdx = ny * w + nx; + if (seen[nIdx]) continue; + if (nIdx !== targetIdx && (Number(alive[nIdx] || 0) | 0) === 1) continue; + seen[nIdx] = 1; + prev[nIdx] = idx; + queue[qt++] = nIdx; + } + } + + if (!seen[targetIdx]) return -1; + let step = targetIdx; + while (prev[step] !== -1 && prev[step] !== fromIdx) { + step = prev[step]; + } + return prev[step] === fromIdx ? step : targetIdx; +} + +export function moveEntityTile(world, fromIdx, toIdx) { + if (fromIdx === toIdx) return; + const scalarKeys = ["E", "reserve", "link", "lineageId", "hue", "age", "born", "died", "W", "clusterField", "superId"]; + for (const key of scalarKeys) { + const arr = world?.[key]; + if (!arr || !ArrayBuffer.isView(arr)) continue; + arr[toIdx] = arr[fromIdx]; + arr[fromIdx] = 0; + } + if (world?.alive && ArrayBuffer.isView(world.alive)) { + world.alive[toIdx] = 1; + world.alive[fromIdx] = 0; + } + const trait = world?.trait; + if (trait && ArrayBuffer.isView(trait)) { + const fromOff = fromIdx * 7; + const toOff = toIdx * 7; + for (let i = 0; i < 7; i++) { + trait[toOff + i] = trait[fromOff + i]; + trait[fromOff + i] = 0; + } + } +} diff --git a/src/game/sim/runtime/processActiveOrderRuntime.js b/src/game/sim/runtime/processActiveOrderRuntime.js new file mode 100644 index 0000000..5489a72 --- /dev/null +++ b/src/game/sim/runtime/processActiveOrderRuntime.js @@ -0,0 +1,148 @@ +import { createEmptyActiveOrder, HARVEST_TICKS } from "../commands/orderCommands.js"; +import { findNextStepBfs4, moveEntityTile } from "./orderNavigation.js"; + +export function processActiveOrderRuntime({ + worldMutable, + preStepAlive, + simOut, + meta, + ticksPerSecond = 24, + harvestTicks = HARVEST_TICKS, +}) { + const activeOrder = simOut.activeOrder; + if (!activeOrder?.active) return { worldMutable, simOut }; + + const w = Number(worldMutable?.w || meta?.gridW || 0) | 0; + const h = Number(worldMutable?.h || meta?.gridH || 0) | 0; + const fromX = Number(activeOrder.fromX) | 0; + const fromY = Number(activeOrder.fromY) | 0; + const targetX = Number(activeOrder.targetX) | 0; + const targetY = Number(activeOrder.targetY) | 0; + const targetIdx = targetY * w + targetX; + const playerLineageId = Number(meta?.playerLineageId || 1) | 0; + let unitIdx = Number(simOut.selectedUnit ?? -1) | 0; + if (unitIdx < 0 || unitIdx >= w * h || (Number(worldMutable.alive?.[unitIdx] || 0) | 0) !== 1) { + unitIdx = fromY * w + fromX; + } + const validUnit = + unitIdx >= 0 && + unitIdx < w * h && + (Number(worldMutable.alive?.[unitIdx] || 0) | 0) === 1 && + (Number(worldMutable.lineageId?.[unitIdx] || 0) | 0) === playerLineageId; + + if (!validUnit || targetX < 0 || targetY < 0 || targetX >= w || targetY >= h) { + simOut.unitOrder = { active: false, fromX: -1, fromY: -1, targetX: -1, targetY: -1 }; + simOut.activeOrder = createEmptyActiveOrder(); + simOut.selectedUnit = -1; + simOut.lastAutoAction = "ORDER_ABORTED"; + return { worldMutable, simOut }; + } + + if (unitIdx === targetIdx) { + const maxProgress = Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0); + const nextProgress = Math.min(maxProgress, (Number(activeOrder.progress || 0) | 0) + 1); + if (nextProgress < maxProgress) { + simOut.activeOrder = { + ...activeOrder, + active: true, + type: "HARVEST", + fromX: unitIdx % w, + fromY: (unitIdx / w) | 0, + targetX, + targetY, + progress: nextProgress, + maxProgress, + }; + simOut.unitOrder = { active: true, fromX: unitIdx % w, fromY: (unitIdx / w) | 0, targetX, targetY }; + simOut.selectedUnit = unitIdx; + simOut.lastAutoAction = `HARVEST_PROGRESS:${nextProgress}/${maxProgress}`; + } else { + simOut.playerDNA = Number(simOut.playerDNA || 0) + 1; + simOut.totalHarvested = Number(simOut.totalHarvested || 0) + 1; + simOut.unitOrder = { active: false, fromX: -1, fromY: -1, targetX: -1, targetY: -1 }; + simOut.activeOrder = createEmptyActiveOrder(); + simOut.selectedUnit = unitIdx; + simOut.lastAutoAction = `HARVEST_AUTO:${targetX},${targetY}`; + } + return { worldMutable, simOut }; + } + + const travelTicks = Math.max(1, ticksPerSecond | 0); + const travelProgress = (Number(activeOrder.progress || 0) | 0) + 1; + if (travelProgress < travelTicks) { + simOut.unitOrder = { + active: true, + fromX: unitIdx % w, + fromY: (unitIdx / w) | 0, + targetX, + targetY, + }; + simOut.activeOrder = { + ...activeOrder, + active: true, + type: "HARVEST", + fromX: unitIdx % w, + fromY: (unitIdx / w) | 0, + targetX, + targetY, + progress: travelProgress, + maxProgress: Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0), + }; + simOut.selectedUnit = unitIdx; + simOut.lastAutoAction = `MOVE_WAIT:${travelProgress}/${travelTicks}`; + return { worldMutable, simOut }; + } + + const navigationWorld = preStepAlive ? { ...worldMutable, alive: preStepAlive } : worldMutable; + const nextIdx = findNextStepBfs4(navigationWorld, unitIdx, targetIdx, w, h); + const occupiedAtTickStart = nextIdx >= 0 && (Number(preStepAlive?.[nextIdx] || 0) | 0) === 1; + const hardBlocked = nextIdx < 0 || (occupiedAtTickStart && nextIdx !== targetIdx); + if (hardBlocked) { + simOut.unitOrder = { + active: true, + fromX: unitIdx % w, + fromY: (unitIdx / w) | 0, + targetX, + targetY, + }; + simOut.activeOrder = { + ...activeOrder, + active: true, + type: "HARVEST", + fromX: unitIdx % w, + fromY: (unitIdx / w) | 0, + targetX, + targetY, + progress: 0, + maxProgress: Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0), + }; + simOut.selectedUnit = unitIdx; + simOut.lastAutoAction = "ORDER_WAIT_BLOCKED"; + return { worldMutable, simOut }; + } + + moveEntityTile(worldMutable, unitIdx, nextIdx); + const nx = nextIdx % w; + const ny = (nextIdx / w) | 0; + simOut.selectedUnit = nextIdx; + simOut.unitOrder = { + active: true, + fromX: nx, + fromY: ny, + targetX, + targetY, + }; + simOut.activeOrder = { + ...activeOrder, + active: true, + type: "HARVEST", + fromX: nx, + fromY: ny, + targetX, + targetY, + progress: 0, + maxProgress: Math.max(1, Number(activeOrder.maxProgress || harvestTicks) | 0), + }; + simOut.lastAutoAction = `MOVE_STEP:${nx},${ny}`; + return { worldMutable, simOut }; +} diff --git a/src/game/sim/runtime/stateCounts.js b/src/game/sim/runtime/stateCounts.js new file mode 100644 index 0000000..36fbffa --- /dev/null +++ b/src/game/sim/runtime/stateCounts.js @@ -0,0 +1,37 @@ +export function countAlivePlayerRoleCells(world, playerLineageId, roleId) { + const zoneRole = world?.zoneRole; + const alive = world?.alive; + const lineageId = world?.lineageId; + if (!zoneRole || !alive || !lineageId) return 0; + let count = 0; + for (let i = 0; i < zoneRole.length; i++) { + if ((Number(zoneRole[i]) | 0) !== (roleId | 0)) continue; + if ((Number(alive[i]) | 0) !== 1) continue; + if ((Number(lineageId[i]) | 0) !== (playerLineageId | 0)) continue; + count++; + } + return count; +} + +export function countAlivePlayerMaskedCells(mask, world, playerLineageId) { + const alive = world?.alive; + const lineageId = world?.lineageId; + if (!mask || !alive || !lineageId) return 0; + let count = 0; + for (let i = 0; i < mask.length; i++) { + if ((Number(mask[i]) | 0) !== 1) continue; + if ((Number(alive[i]) | 0) !== 1) continue; + if ((Number(lineageId[i]) | 0) !== (playerLineageId | 0)) continue; + count++; + } + return count; +} + +export function countMaskOnes(mask) { + if (!mask || !ArrayBuffer.isView(mask)) return 0; + let count = 0; + for (let i = 0; i < mask.length; i++) { + if ((Number(mask[i]) | 0) === 1) count++; + } + return count; +} diff --git a/tests/test-active-order-runtime.mjs b/tests/test-active-order-runtime.mjs index 2a2f471..b51772a 100644 --- a/tests/test-active-order-runtime.mjs +++ b/tests/test-active-order-runtime.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { createEmptyActiveOrder, HARVEST_TICKS } from "../src/game/sim/commands/orderCommands.js"; -import { processActiveOrderRuntime } from "../src/game/runtime/processActiveOrderRuntime.js"; +import { processActiveOrderRuntime } from "../src/game/sim/runtime/processActiveOrderRuntime.js"; function makeWorld({ w = 4, h = 1, alive = [], lineageId = [], R = [] } = {}) { const size = w * h; From 246da43cff322e1b26b324f1e8dc15ad0951e4dc Mon Sep 17 00:00:00 2001 From: Vannon Date: Thu, 26 Mar 2026 18:43:23 +0100 Subject: [PATCH 2/4] refactor(ui): route ui state reads through viewmodel selectors --- src/game/ui/ui.input.js | 64 +++++++++++-------- src/game/ui/ui.js | 12 ++-- src/game/viewmodel/builderResources.js | 9 +++ src/game/viewmodel/builderSelectors.js | 14 ++++ .../viewmodel/tileInteractionSelectors.js | 25 ++++++++ src/game/viewmodel/uiStateSelectors.js | 35 ++++++++++ 6 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 src/game/viewmodel/builderResources.js create mode 100644 src/game/viewmodel/builderSelectors.js create mode 100644 src/game/viewmodel/tileInteractionSelectors.js create mode 100644 src/game/viewmodel/uiStateSelectors.js diff --git a/src/game/ui/ui.input.js b/src/game/ui/ui.input.js index 3b4f3b7..1510ed7 100644 --- a/src/game/ui/ui.input.js +++ b/src/game/ui/ui.input.js @@ -1,6 +1,18 @@ import { BRUSH_MODE, RUN_PHASE, isBuilderBrushMode } from "../contracts/ids.js"; -import { getBrushTiles } from "../sim/brushShapes.js"; -import { selectAreAllTilesFilled, formatSeedDisplay } from "../sim/mapSeedGen.js"; +import { + selectBuilderBrushTiles, + selectCanGenerateMapSeed, + selectSeedDisplay, +} from "../viewmodel/builderSelectors.js"; +import { selectTileInteraction } from "../viewmodel/tileInteractionSelectors.js"; +import { + selectBrushContext, + selectGridSize, + selectIsMapBuilder, + selectIsRunning, + selectRunPhase, + selectUiMeta, +} from "../viewmodel/uiStateSelectors.js"; export function installUiInput(UI) { Object.assign(UI.prototype, { @@ -17,9 +29,9 @@ export function installUiInput(UI) { const toggleMapBuilder = () => { const state = this._store.getState(); - const runPhase = String(state?.sim?.runPhase || ""); + const runPhase = selectRunPhase(state); const entering = runPhase !== RUN_PHASE.MAP_BUILDER; - const ui = state?.meta?.ui || {}; + const ui = selectUiMeta(state); if (entering) { this._builderPrevUi = { activeTab: String(ui.activeTab || "lage"), @@ -70,13 +82,13 @@ export function installUiInput(UI) { const togglePlay = () => { const state = this._store.getState(); - const running = !!state.sim.running; + const running = selectIsRunning(state); this._dispatch({ type:"TOGGLE_RUNNING", payload:{ running:!running } }); }; const openBuilder = () => { const state = this._store.getState(); - const runPhase = String(state?.sim?.runPhase || ""); + const runPhase = selectRunPhase(state); if (runPhase === RUN_PHASE.MAP_BUILDER) { this._setActionFeedback({ ok: true, @@ -90,7 +102,7 @@ export function installUiInput(UI) { this._btnPlay?.addEventListener("click", togglePlay); this._btnStep?.addEventListener("click", () => { - if (this._store.getState().sim.running) + if (selectIsRunning(this._store.getState())) this._dispatch({ type:"TOGGLE_RUNNING", payload:{ running:false } }); this._dispatch({ type:"SIM_STEP", payload:{} }); }); @@ -135,15 +147,15 @@ export function installUiInput(UI) { // Seed generation button this._builderSeedGenBtn?.addEventListener("click", () => { const state = this._store.getState(); - if (!selectAreAllTilesFilled(state)) { + if (!selectCanGenerateMapSeed(state)) { this._setActionFeedback({ ok: false, message: "Nicht alle Tiles belegt.", hint: "Alle Tiles muessen belegt sein." }); return; } this._dispatch({ type: "GENERATE_MAP_SEED", payload: {} }); const nextState = this._store.getState(); const seed = nextState?.map?.spec?.generatedSeed || ""; - if (this._builderSeedLabel) this._builderSeedLabel.textContent = `Seed: ${seed ? formatSeedDisplay(seed) : "\u2014"}`; - this._setActionFeedback({ ok: true, message: `Seed: ${seed ? formatSeedDisplay(seed) : "\u2014"}`, hint: "" }); + if (this._builderSeedLabel) this._builderSeedLabel.textContent = `Seed: ${seed ? selectSeedDisplay(seed) : "\u2014"}`; + this._setActionFeedback({ ok: true, message: `Seed: ${seed ? selectSeedDisplay(seed) : "\u2014"}`, hint: "" }); }); if (this._dockPlayBtn) this._dockPlayBtn.addEventListener("click", togglePlay); @@ -175,7 +187,7 @@ export function installUiInput(UI) { window.addEventListener("keydown", (e) => { if (e.target && ["INPUT","SELECT","TEXTAREA"].includes(e.target.tagName)) return; const state = this._store.getState(); - const isBuilder = String(state?.sim?.runPhase || "") === RUN_PHASE.MAP_BUILDER; + const isBuilder = selectIsMapBuilder(state); if (e.code === "Space") { e.preventDefault(); this._btnPlay?.click?.(); @@ -249,19 +261,14 @@ export function installUiInput(UI) { const sx = clientX - rect.left, sy = clientY - rect.top; const wx = Math.floor((sx*dpr - offX) / tilePx); const wy = Math.floor((sy*dpr - offY) / tilePx); - if (wx<0||wy<0||wx>=state.meta.gridW||wy>=state.meta.gridH) return; - const runPhase = String(state.sim?.runPhase || ""); + const { gridW, gridH } = selectGridSize(state); + if (wx<0||wy<0||wx>=gridW||wy>=gridH) return; + const { runPhase, isBuilder: builderActive, mode, radius } = selectBrushContext(state, this._builderMode); const shiftRemove = !!pointerEvent?.shiftKey; - const builderActive = runPhase === RUN_PHASE.MAP_BUILDER; - const mode = builderActive ? this._builderMode : state.meta.brushMode; - const radius = state.meta.brushRadius || 3; - const idx = wy * state.meta.gridW + wx; - const playerLineageId = Number(state.meta.playerLineageId || 1) | 0; - const isOwnAliveTile = - (Number(state.world?.alive?.[idx] || 0) | 0) === 1 && - (Number(state.world?.lineageId?.[idx] || 0) | 0) === playerLineageId; - const resourceValue = Number(state.world?.R?.[idx] || 0); - const isResourceTile = resourceValue > 0.05; + const tileInteraction = selectTileInteraction(state, wx, wy); + const idx = tileInteraction.idx; + const isOwnAliveTile = tileInteraction.isOwnAliveTile; + const isResourceTile = tileInteraction.isResourceTile; if (this._isLabOnlyBrushMode(mode) && !this._isLaborPanelActive(state)) { this._ensureLabBrushIsolation(this._activeContext || "lage", state); @@ -386,9 +393,9 @@ export function installUiInput(UI) { // Handle new builder brush modes (surface, resource, eraser) if (isBuilderBrushMode(mode)) { const brushSize = this._builderBrushSize || 1; - const gridW = state.meta.gridW || 16; - const gridH = state.meta.gridH || 16; - const tiles = getBrushTiles(wx, wy, brushSize, gridW, gridH); + const gridW = Number(state?.meta?.gridW || 16) | 0; + const gridH = Number(state?.meta?.gridH || 16) | 0; + const tiles = selectBuilderBrushTiles(gridW, gridH, wx, wy, brushSize); if (!tiles.length) return; const spec = state.map?.spec || {}; const prevSurfacePlan = spec.surfacePlan && typeof spec.surfacePlan === "object" ? { ...spec.surfacePlan } : {}; @@ -457,7 +464,7 @@ export function installUiInput(UI) { const updateHover = (e) => { if (!this._rInfo) return; const state = this._store.getState(); - if (String(state?.sim?.runPhase || "") !== RUN_PHASE.MAP_BUILDER) { + if (!selectIsMapBuilder(state)) { this._setBuilderHover(null); return; } @@ -467,7 +474,8 @@ export function installUiInput(UI) { const sy = e.clientY - rect.top; const wx = Math.floor((sx * dpr - offX) / tilePx); const wy = Math.floor((sy * dpr - offY) / tilePx); - if (wx < 0 || wy < 0 || wx >= state.meta.gridW || wy >= state.meta.gridH) { + const { gridW, gridH } = selectGridSize(state); + if (wx < 0 || wy < 0 || wx >= gridW || wy >= gridH) { this._setBuilderHover(null); return; } diff --git a/src/game/ui/ui.js b/src/game/ui/ui.js index cddebbc..746bb61 100644 --- a/src/game/ui/ui.js +++ b/src/game/ui/ui.js @@ -8,7 +8,7 @@ import { createBuilderHistory } from "./ui.history.js"; import { createCircleMenu } from "./ui.circleMenu.js"; import { createViewportController } from "./ui.viewport.js"; import { getCursorStyle } from "./ui.cursors.js"; -import { getBrushTiles } from "../sim/brushShapes.js"; +import { selectIsMapBuilder, selectRunPhase, selectUiMeta } from "../viewmodel/uiStateSelectors.js"; import { SURFACE_TYPE, SURFACE_TYPE_VALUES, @@ -17,8 +17,7 @@ import { RESOURCE_STAGE, SURFACE_TYPE_LABEL, RESOURCE_KIND_BUILDER_LABEL, -} from "../sim/mapBuilderResources.js"; -import { selectAreAllTilesFilled, generateMapSeed, formatSeedDisplay } from "../sim/mapSeedGen.js"; +} from "../viewmodel/builderResources.js"; const BUILDER_TILE_OPTIONS = Object.freeze([ Object.freeze({ mode: "light", label: "Licht", hint: "Lichtwert setzen", value: 0.92, accent: "#ffd47a" }), @@ -122,8 +121,7 @@ export class UI { if (this._timer) { this._timer.textContent = `Timer ${this._formatTimer(tick)}`; } - const runPhase = String(current?.sim?.runPhase || ""); - const isBuilder = runPhase === RUN_PHASE.MAP_BUILDER; + const isBuilder = selectIsMapBuilder(current); this._syncBuilderPhaseUi?.(current, isBuilder); this._syncBuilderHoverOverlay?.(current, isBuilder); this._refreshActionFeedbackView?.(current, isBuilder); @@ -210,7 +208,7 @@ export class UI { } _syncBuilderPhaseUi(state = this._store?.getState?.(), isBuilder = String(state?.sim?.runPhase || "") === RUN_PHASE.MAP_BUILDER) { - const ui = state?.meta?.ui || {}; + const ui = selectUiMeta(state); const cfg = this._getBuilderModeConfig(this._builderMode); const modeLabel = cfg ? cfg.label : "Licht"; const running = !!state?.sim?.running; @@ -244,7 +242,7 @@ export class UI { this._builderPanel.setAttribute("aria-hidden", isBuilder ? "false" : "true"); } if (this._builderPanelState) { - this._builderPanelState.textContent = isBuilder ? `Phase: ${String(state?.sim?.runPhase || "").replace(/_/g, " ")}` : "Phase: inaktiv"; + this._builderPanelState.textContent = isBuilder ? `Phase: ${selectRunPhase(state).replace(/_/g, " ")}` : "Phase: inaktiv"; } if (this._builderPanelMode) { this._builderPanelMode.textContent = `Aktives Werkzeug: ${modeLabel}`; diff --git a/src/game/viewmodel/builderResources.js b/src/game/viewmodel/builderResources.js new file mode 100644 index 0000000..ace6131 --- /dev/null +++ b/src/game/viewmodel/builderResources.js @@ -0,0 +1,9 @@ +export { + SURFACE_TYPE, + SURFACE_TYPE_VALUES, + RESOURCE_KIND_BUILDER, + RESOURCE_KIND_BUILDER_VALUES, + RESOURCE_STAGE, + SURFACE_TYPE_LABEL, + RESOURCE_KIND_BUILDER_LABEL, +} from "../sim/mapBuilderResources.js"; diff --git a/src/game/viewmodel/builderSelectors.js b/src/game/viewmodel/builderSelectors.js new file mode 100644 index 0000000..9afd2d3 --- /dev/null +++ b/src/game/viewmodel/builderSelectors.js @@ -0,0 +1,14 @@ +import { getBrushTiles } from "../sim/brushShapes.js"; +import { selectAreAllTilesFilled, formatSeedDisplay } from "../sim/mapSeedGen.js"; + +export function selectBuilderBrushTiles(gridW, gridH, x, y, size) { + return getBrushTiles(x, y, size, gridW, gridH); +} + +export function selectCanGenerateMapSeed(state) { + return selectAreAllTilesFilled(state); +} + +export function selectSeedDisplay(seed) { + return formatSeedDisplay(seed); +} diff --git a/src/game/viewmodel/tileInteractionSelectors.js b/src/game/viewmodel/tileInteractionSelectors.js new file mode 100644 index 0000000..67cd982 --- /dev/null +++ b/src/game/viewmodel/tileInteractionSelectors.js @@ -0,0 +1,25 @@ +function isPlayerAliveTile(state, idx) { + const playerLineageId = Number(state?.meta?.playerLineageId || 1) | 0; + return (Number(state?.world?.alive?.[idx] || 0) | 0) === 1 + && (Number(state?.world?.lineageId?.[idx] || 0) | 0) === playerLineageId; +} + +export function selectTileInteraction(state, x, y) { + const gridW = Number(state?.meta?.gridW || 0) | 0; + const gridH = Number(state?.meta?.gridH || 0) | 0; + if (x < 0 || y < 0 || x >= gridW || y >= gridH) { + return { + valid: false, + idx: -1, + isOwnAliveTile: false, + isResourceTile: false, + }; + } + const idx = y * gridW + x; + return { + valid: true, + idx, + isOwnAliveTile: isPlayerAliveTile(state, idx), + isResourceTile: Number(state?.world?.R?.[idx] || 0) > 0.05, + }; +} diff --git a/src/game/viewmodel/uiStateSelectors.js b/src/game/viewmodel/uiStateSelectors.js new file mode 100644 index 0000000..cf03b48 --- /dev/null +++ b/src/game/viewmodel/uiStateSelectors.js @@ -0,0 +1,35 @@ +import { RUN_PHASE } from "../contracts/ids.js"; + +export function selectRunPhase(state) { + return String(state?.sim?.runPhase || ""); +} + +export function selectIsMapBuilder(state) { + return selectRunPhase(state) === RUN_PHASE.MAP_BUILDER; +} + +export function selectIsRunning(state) { + return !!state?.sim?.running; +} + +export function selectGridSize(state) { + return { + gridW: Number(state?.meta?.gridW || 0) | 0, + gridH: Number(state?.meta?.gridH || 0) | 0, + }; +} + +export function selectBrushContext(state, builderMode) { + const runPhase = selectRunPhase(state); + const isBuilder = runPhase === RUN_PHASE.MAP_BUILDER; + return { + runPhase, + isBuilder, + mode: isBuilder ? builderMode : state?.meta?.brushMode, + radius: Number(state?.meta?.brushRadius || 3) | 0, + }; +} + +export function selectUiMeta(state) { + return state?.meta?.ui && typeof state.meta.ui === "object" ? state.meta.ui : {}; +} From c465ee5b61fd60fd39736283e842f5a2aafdd82d Mon Sep 17 00:00:00 2001 From: Vannon Date: Thu, 26 Mar 2026 18:43:46 +0100 Subject: [PATCH 3/4] refactor(kernel): split web persistence into explicit platform adapter --- devtools/demo-live-attest.mjs | 9 +- src/app/main.js | 3 + src/kernel/store/createStore.js | 4 +- src/kernel/store/persistence.js | 84 +------------------ src/platform/persistence/webDriver.js | 61 ++++++++++++++ tests/test-persistence-drivers.mjs | 3 +- tests/test-persistence-map-builder-reload.mjs | 10 ++- tests/test-ui-foundation-e2e.mjs | 11 ++- 8 files changed, 91 insertions(+), 94 deletions(-) create mode 100644 src/platform/persistence/webDriver.js diff --git a/devtools/demo-live-attest.mjs b/devtools/demo-live-attest.mjs index 549d226..4868d69 100644 --- a/devtools/demo-live-attest.mjs +++ b/devtools/demo-live-attest.mjs @@ -154,8 +154,9 @@ try { await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); await page.evaluate(async () => { - const [{ createStore }, manifestMod, logicMod, rendererMod, uiMod, idsMod, presetsMod] = await Promise.all([ + const [{ createStore }, webDriverMod, manifestMod, logicMod, rendererMod, uiMod, idsMod, presetsMod] = await Promise.all([ import("/src/kernel/store/createStore.js"), + import("/src/platform/persistence/webDriver.js"), import("/src/game/manifest.js"), import("/src/game/runtime/index.js"), import("/src/game/render/renderer.js"), @@ -176,7 +177,11 @@ try { canvas.width = Math.floor(1200 * dpr); canvas.height = Math.floor(800 * dpr); - const store = createStore(manifestMod.manifest, { reducer: logicMod.reducer, simStep: logicMod.simStepPatch }); + const store = createStore( + manifestMod.manifest, + { reducer: logicMod.reducer, simStep: logicMod.simStepPatch }, + { storageDriver: webDriverMod.getDefaultWebDriver() }, + ); const render = rendererMod.render; const ui = new uiMod.UI(store, canvas); diff --git a/src/app/main.js b/src/app/main.js index 4d272ca..3b60181 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -8,6 +8,7 @@ import { reducer, simStepPatch, shouldAdvanceSimulation } from "../game/runtime/ import { render } from "../game/render/renderer.js"; import { UI } from "../game/ui/ui.js"; import { hashString } from "../kernel/determinism/rng.js"; +import { getDefaultWebDriver } from "../platform/persistence/webDriver.js"; import { createWorldStateLog } from "./runtime/worldStateLog.js"; import { bindBootStatusErrorHooks, setBootStatus } from "./runtime/bootStatus.js"; @@ -66,6 +67,8 @@ const DebugRuntime = { const store = createStore(runtimeManifest, { reducer, simStep: simStepPatch, +}, { + storageDriver: getDefaultWebDriver(), }); globalThis.__LIFEGAMELAB_STORE__ = store; // Initialize Log with Seed from Store diff --git a/src/kernel/store/createStore.js b/src/kernel/store/createStore.js index 0345cb8..c7104f0 100644 --- a/src/kernel/store/createStore.js +++ b/src/kernel/store/createStore.js @@ -5,7 +5,7 @@ import { validateActionAgainstSchema } from "../validation/validateAction.js"; import { assertDomainPatchesAllowed } from "../validation/assertDomainPatchesAllowed.js"; import { createRngStreamsScoped } from "../determinism/rng.js"; import { runWithDeterminismGuard, deepFreeze } from "../determinism/runtimeGuards.js"; -import { getDefaultDriver } from "./persistence.js"; +import { createNullDriver } from "./persistence.js"; import { isPlainObject } from "../shared/isPlainObject.js"; export function createStore(runtimeManifest, project, options = {}) { @@ -14,7 +14,7 @@ export function createStore(runtimeManifest, project, options = {}) { const simStepActionType = resolveSimStepActionType(runtimeManifest); const simStepMutationAllowed = mutationMatrix[simStepActionType]; assertManifestContracts(runtimeManifest); - const driver = options.storageDriver || getDefaultDriver(); + const driver = options.storageDriver || createNullDriver(); const adaptAction = typeof options.actionAdapter === "function" ? options.actionAdapter : (typeof project.adaptAction === "function" ? project.adaptAction : (a) => a); diff --git a/src/kernel/store/persistence.js b/src/kernel/store/persistence.js index 67a00c2..a9d8031 100644 --- a/src/kernel/store/persistence.js +++ b/src/kernel/store/persistence.js @@ -1,5 +1,5 @@ // ============================================================ -// Universal Persistence — Platform Agnostic Driver Interface +// Kernel Persistence Driver Interface (platform-neutral) // ============================================================ /** @@ -9,85 +9,5 @@ export const createNullDriver = () => ({ load: () => null, save: () => {}, - export: (doc) => console.log("Export not implemented for this platform", doc) + export: (doc) => console.log("Export not implemented for this platform", doc), }); - -/** - * createWebDriver - * Full state persistence — WARNING: TypedArrays (Float32Array, Uint8Array etc.) - * serialise as plain objects via JSON. Use createMetaOnlyWebDriver for game states. - */ -export const createWebDriver = (key = "llm_kernel_state") => ({ - load: () => { - const raw = localStorage.getItem(key); - if (raw == null) return null; - return JSON.parse(raw); - }, - save: (doc) => { - localStorage.setItem(key, JSON.stringify(doc)); - }, - export: (doc) => { - const blob = new Blob([JSON.stringify(doc, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "state_export.json"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } -}); - -/** - * createMetaOnlyWebDriver - * Saves `meta` and `map` only. - * Skips `world` (TypedArrays → would corrupt on JSON round-trip) and - * `sim` (ephemeral tick-stats, always recomputed after GEN_WORLD + first SIM_STEP). - * - * Benefits: - * - No TypedArray corruption on page reload - * - ~100× smaller save payload → no frame-budget impact - * - User settings (speed, grid size, render mode) persist across reloads - * - World always regenerates fresh via GEN_WORLD on boot - */ -export const createMetaOnlyWebDriver = (key = "llm_kernel_meta_v1") => ({ - load: () => { - const raw = localStorage.getItem(key); - if (raw == null) return null; - const doc = JSON.parse(raw); - // Re-inject empty world/sim so sanitizeBySchema fills in defaults. - if (doc && doc.state) { - if (!doc.state.map || typeof doc.state.map !== "object" || Array.isArray(doc.state.map)) { - doc.state.map = {}; - } - doc.state.world = {}; - doc.state.sim = {}; - } - return doc; - }, - save: (doc) => { - const slim = { - schemaVersion: doc.schemaVersion, - updatedAt: doc.updatedAt, - revisionCount: doc.revisionCount, - state: { - meta: doc.state.meta, - map: doc.state.map || {}, - world: {}, - sim: {}, - }, - }; - localStorage.setItem(key, JSON.stringify(slim)); - }, - export: createWebDriver().export, -}); - -// Autodetect default driver -export const getDefaultDriver = () => { - if (typeof localStorage !== "undefined" && typeof document !== "undefined") { - // Meta-only driver: fast, TypedArray-safe, preserves user settings. - return createMetaOnlyWebDriver(); - } - return createNullDriver(); -}; diff --git a/src/platform/persistence/webDriver.js b/src/platform/persistence/webDriver.js new file mode 100644 index 0000000..6f0560a --- /dev/null +++ b/src/platform/persistence/webDriver.js @@ -0,0 +1,61 @@ +import { createNullDriver } from "../../kernel/store/persistence.js"; + +export const createWebDriver = (key = "llm_kernel_state") => ({ + load: () => { + const raw = localStorage.getItem(key); + if (raw == null) return null; + return JSON.parse(raw); + }, + save: (doc) => { + localStorage.setItem(key, JSON.stringify(doc)); + }, + export: (doc) => { + const blob = new Blob([JSON.stringify(doc, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "state_export.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +}); + +export const createMetaOnlyWebDriver = (key = "llm_kernel_meta_v1") => ({ + load: () => { + const raw = localStorage.getItem(key); + if (raw == null) return null; + const doc = JSON.parse(raw); + if (doc && doc.state) { + if (!doc.state.map || typeof doc.state.map !== "object" || Array.isArray(doc.state.map)) { + doc.state.map = {}; + } + doc.state.world = {}; + doc.state.sim = {}; + } + return doc; + }, + save: (doc) => { + const slim = { + schemaVersion: doc.schemaVersion, + updatedAt: doc.updatedAt, + revisionCount: doc.revisionCount, + state: { + meta: doc.state.meta, + map: doc.state.map || {}, + world: {}, + sim: {}, + }, + }; + localStorage.setItem(key, JSON.stringify(slim)); + }, + export: createWebDriver().export, +}); + +export function getDefaultWebDriver() { + if (typeof localStorage !== "undefined" && typeof document !== "undefined") { + return createMetaOnlyWebDriver(); + } + return createNullDriver(); +} diff --git a/tests/test-persistence-drivers.mjs b/tests/test-persistence-drivers.mjs index 31e157e..0d5d2f1 100644 --- a/tests/test-persistence-drivers.mjs +++ b/tests/test-persistence-drivers.mjs @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { createStore } from "../src/kernel/store/createStore.js"; -import { createMetaOnlyWebDriver, createNullDriver, createWebDriver } from "../src/kernel/store/persistence.js"; +import { createNullDriver } from "../src/kernel/store/persistence.js"; +import { createMetaOnlyWebDriver, createWebDriver } from "../src/platform/persistence/webDriver.js"; import { manifest } from "../src/game/manifest.js"; import { reducer, simStepPatch } from "../src/game/runtime/index.js"; import { getStartWindowRange, getWorldPreset } from "../src/game/sim/worldPresets.js"; diff --git a/tests/test-persistence-map-builder-reload.mjs b/tests/test-persistence-map-builder-reload.mjs index b349d68..1275158 100644 --- a/tests/test-persistence-map-builder-reload.mjs +++ b/tests/test-persistence-map-builder-reload.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { createStore } from "../src/kernel/store/createStore.js"; +import { createMetaOnlyWebDriver } from "../src/platform/persistence/webDriver.js"; import { RUN_PHASE } from "../src/game/contracts/ids.js"; import { manifest } from "../src/game/manifest.js"; import { reducer, simStepPatch } from "../src/game/runtime/index.js"; @@ -10,7 +11,8 @@ const storageKey = "llm_kernel_meta_v1"; const stubs = installWebStubs(); try { - const store = createStore(manifest, { reducer, simStep: simStepPatch }); + const storageDriver = createMetaOnlyWebDriver(storageKey); + const store = createStore(manifest, { reducer, simStep: simStepPatch }, { storageDriver }); store.dispatch({ type: "SET_SEED", payload: { seed: "persist-builder-reload" } }); store.dispatch({ type: "GEN_WORLD", payload: {} }); const gridW = Number(store.getState().meta.gridW || 0); @@ -22,7 +24,7 @@ try { }); const raw = stubs.storage.get(storageKey); - assert.equal(typeof raw, "string", "default web persistence must write a JSON payload"); + assert.equal(typeof raw, "string", "explicit web persistence must write a JSON payload"); const parsed = JSON.parse(raw); assert.equal(parsed.state.map.activeSource, "mapspec", "persisted payload must keep mapspec activation"); assert.deepEqual( @@ -31,7 +33,7 @@ try { "persisted payload must keep tilePlan edits", ); - const reloadStore = createStore(manifest, { reducer, simStep: simStepPatch }); + const reloadStore = createStore(manifest, { reducer, simStep: simStepPatch }, { storageDriver }); const reloadedState = reloadStore.getState(); assert.equal(reloadedState.map.activeSource, "mapspec", "reloaded store must restore mapspec activation"); assert.deepEqual( @@ -55,4 +57,4 @@ try { stubs.restore(); } -console.log("PERSISTENCE_MAP_BUILDER_RELOAD_OK mapspec tilePlan survives reload and GEN_WORLD"); +console.log("PERSISTENCE_MAP_BUILDER_RELOAD_OK mapspec tilePlan survives explicit web-driver reload and GEN_WORLD"); diff --git a/tests/test-ui-foundation-e2e.mjs b/tests/test-ui-foundation-e2e.mjs index f32a3db..913cb74 100644 --- a/tests/test-ui-foundation-e2e.mjs +++ b/tests/test-ui-foundation-e2e.mjs @@ -42,14 +42,19 @@ try { assert.equal(canvasExists, 1, "main app must bootstrap canvas#cv"); const e2e = await page.evaluate(async () => { - const [{ createStore }, manifestMod, logicMod] = await Promise.all([ + const [{ createStore }, webDriverMod, manifestMod, logicMod] = await Promise.all([ import("/src/kernel/store/createStore.js"), + import("/src/platform/persistence/webDriver.js"), import("/src/game/manifest.js"), import("/src/game/runtime/index.js"), ]); - const store = createStore(manifestMod.manifest, { reducer: logicMod.reducer, simStep: logicMod.simStepPatch }); -store.dispatch({ type: "SET_SEED", payload: { seed: "ui-e2e-seed-main" } }); + const store = createStore( + manifestMod.manifest, + { reducer: logicMod.reducer, simStep: logicMod.simStepPatch }, + { storageDriver: webDriverMod.getDefaultWebDriver() }, + ); + store.dispatch({ type: "SET_SEED", payload: { seed: "ui-e2e-seed-main" } }); store.dispatch({ type: "GEN_WORLD", payload: {} }); store.dispatch({ type: "SET_UI", payload: { runPhase: "run_active" } }); store.dispatch({ type: "TOGGLE_RUNNING", payload: { running: true } }); From df4b0066ee090a496cdeca3107c3562521276216 Mon Sep 17 00:00:00 2001 From: Vannon Date: Thu, 26 Mar 2026 18:43:58 +0100 Subject: [PATCH 4/4] test(determinism): add patch-chain guard and refresh architecture docs --- docs/ARCHITECTURE.md | 5 ++ docs/STATUS.md | 9 +++ tests/evidence/spec-map.mjs | 6 ++ tests/test-deterministic-patch-chain.mjs | 77 ++++++++++++++++++++++++ tests/test-runtime-boundaries.mjs | 30 +++++++++ 5 files changed, 127 insertions(+) create mode 100644 tests/test-deterministic-patch-chain.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2b9e0f2..4027143 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,6 +26,10 @@ - `tools/llm/*`: dev-only LLM adapters, read models and gate tooling. Runtime code must not import them. ## Current Cleanup Slice +- Sim runtime extraction continued: active-order execution, navigation, infra helper logic and shared state counters now live under `src/game/sim/runtime/*`; `src/game/runtime/*` keeps compatibility re-export facades only. +- UI/sim decoupling continued: UI no longer imports `src/game/sim/*` directly; read decisions for builder tiles, seed display and tile interaction now flow through `src/game/viewmodel/*` selectors. +- UI state access consolidation started: run-phase/running/grid/brush context reads now route through `src/game/viewmodel/uiStateSelectors.js` instead of ad-hoc inline state field access. +- Kernel/platform split continued: browser persistence drivers moved to `src/platform/persistence/webDriver.js`; kernel persistence now stays platform-neutral and app/test callsites inject storage drivers explicitly. - Foundation eligibility now belongs to `src/game/runtime/foundationEligibility.js`; `src/game/sim/foundationEligibility.js` is compatibility-only. - Fog read-model shaping now belongs to `src/game/viewmodel/fogIntel.js`; `src/game/render/fogOfWar.js` is render-only again. - Lage-panel read helpers now belong to `src/game/viewmodel/lageStats.js` instead of being embedded inside the UI renderer. @@ -64,6 +68,7 @@ - Operative reducer path remains `src/game/sim/reducer/index.js`. - `src/game/sim/reducer.js` remains the compatibility facade. - `src/game/runtime/index.js` remains the stable public sim-step surface even though active order execution now delegates into runtime helper modules. +- `src/game/runtime/*` helper modules are now compatibility facades that re-export canonical implementations from `src/game/sim/runtime/*`. - `src/game/sim/worldPresets.js`, `src/game/sim/mapspec.js`, and `src/game/sim/worldgen.js` remain the stable path-pinned surfaces consumed by runtime and tests. - Boot still dispatches `GEN_WORLD`, but world boot now compiles through `map.spec`. - Renderer orchestration in `src/app/main.js` and `src/game/render/renderer.js` remains canonical and reusable. diff --git a/docs/STATUS.md b/docs/STATUS.md index 671a3b8..0b4f249 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,10 @@ # STATUS - Current Head ## Snapshot (2026-03-20) +- Runtime/sim boundary cleanup continued (2026-03-26): active-order execution, order navigation, infra helpers and shared state counters moved from `src/game/runtime/*` into canonical `src/game/sim/runtime/*`; runtime paths remain compatibility re-exports. +- UI decoupling continued (2026-03-26): direct `ui -> sim` imports were removed in favor of `src/game/viewmodel/*` selector surfaces (`builderSelectors`, `tileInteractionSelectors`, `uiStateSelectors`). +- Kernel/platform split continued (2026-03-26): browser persistence moved to `src/platform/persistence/webDriver.js`; kernel store now defaults to explicit neutral driver behavior and app/tests inject web drivers where needed. +- Determinism guard coverage expanded (2026-03-26): new `tests/test-deterministic-patch-chain.mjs` verifies same-seed/same-action replay emits identical reducer/simStep patch chains and signature-material hashes. - Runtime now boots directly against canonical `src/game/*` and `src/kernel/*` modules; legacy `src/project/*` and `src/core/kernel/*` facades were removed. - Dev-only LLM helpers now live under `tools/llm/*`, and runtime/UI imports no longer depend on LLM modules. - Sim cleanup follow-up landed: foundation eligibility moved to `src/game/runtime/foundationEligibility.js`, fog intel moved to `src/game/viewmodel/fogIntel.js`, and Lage-panel stat helpers moved to `src/game/viewmodel/lageStats.js`. @@ -56,11 +60,16 @@ - `src/game/render/fogOfWar.js` now contains render-only fog logic; advisor fog shaping moved into `src/game/viewmodel/fogIntel.js`. - `src/game/sim/reducer/index.js` now re-exports `shouldAdvanceSimulation` while consuming extracted gate and order command modules instead of defining them inline. - `src/game/sim/reducer/index.js` now delegates active order execution through `src/game/runtime/processActiveOrderRuntime.js` while keeping the runtime/public export surface stable. +- `src/game/sim/reducer/core.js` now consumes canonical simulation runtime helpers from `src/game/sim/runtime/*` and no longer imports helper logic from `src/game/runtime/*`. - `src/game/runtime/stateCounts.js` now owns shared role/mask counting helpers used by both `src/game/sim/reducer/index.js` and `src/game/sim/reducer/winConditions.js`. - `src/game/runtime/infraRuntime.js` now owns shared infra staging helpers used by `src/game/sim/reducer/index.js` for candidate-mask cloning and committed-anchor checks. +- `src/game/runtime/infraRuntime.js`, `src/game/runtime/stateCounts.js`, `src/game/runtime/orderNavigation.js`, and `src/game/runtime/processActiveOrderRuntime.js` now act as compatibility re-export facades to `src/game/sim/runtime/*`. +- `src/game/viewmodel/uiStateSelectors.js` now centralizes run-phase/running/grid/brush-context reads for UI modules. +- `src/platform/persistence/webDriver.js` now owns browser persistence drivers; `src/kernel/store/persistence.js` stays platform-neutral. - `src/game/sim/world/presetCatalog.js`, `src/game/sim/world/presetRuntime.js`, `src/game/sim/world/generationRuntime.js`, and `src/game/sim/mapspec/runtime.js` now own the moved preset/worldgen/MapSpec logic while the old top-level sim paths remain stable facades. - `src/game/sim/grid/index.js` now owns shared 8-neighbor founder connectivity checks used by `src/game/runtime/foundationEligibility.js` and `src/game/sim/gates/phaseGates.js`. - `tests/test-active-order-runtime.mjs` now hardens blocked, wait, harvest-progress, and harvest-complete branches for the extracted active-order runtime. +- `tests/test-deterministic-patch-chain.mjs` now verifies same-seed action replay patch-chain equivalence (reducer + simStep phases) and signature-material stability. ## Traceability Added - `docs/traceability/rebuild-preparation-inventory.md` diff --git a/tests/evidence/spec-map.mjs b/tests/evidence/spec-map.mjs index dc25a41..806a29f 100644 --- a/tests/evidence/spec-map.mjs +++ b/tests/evidence/spec-map.mjs @@ -184,6 +184,12 @@ const REGRESSION_TEST_ENTRIES = Object.freeze([ purpose: "prove seed + action replay yields stable signature chain and cross-seed divergence", counterProbe: "signature chain diverges under seed perturbation and matches under identical replay", }], + ["tests/test-deterministic-patch-chain.mjs", { + status: "verified", + budgetMs: 120_000, + purpose: "prove same-seed action replay yields an identical reducer/simStep patch sequence and stable final signature material", + counterProbe: "patch-sequence perturbation is detected even when coarse final-state checks would pass", + }], ["tests/test-sim-gate-contract.mjs", { status: "verified", budgetMs: 90_000, diff --git a/tests/test-deterministic-patch-chain.mjs b/tests/test-deterministic-patch-chain.mjs new file mode 100644 index 0000000..c33486c --- /dev/null +++ b/tests/test-deterministic-patch-chain.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import { createStore } from "../src/kernel/store/createStore.js"; +import { createNullDriver } from "../src/kernel/store/persistence.js"; +import { manifest } from "../src/game/manifest.js"; +import { reducer, simStepPatch } from "../src/game/runtime/index.js"; +import { getStartWindowRange, getWorldPreset } from "../src/game/sim/worldPresets.js"; +import { stableStringify } from "../src/kernel/store/signature.js"; + +function sha256Text(text) { + return createHash("sha256").update(String(text), "utf8").digest("hex"); +} + +function createInstrumentedStore(seed, patchLog) { + return createStore( + manifest, + { + reducer: (state, action, ctx) => { + const patches = reducer(state, action, ctx); + patchLog.push({ phase: "reducer", actionType: action?.type || "", patches: stableStringify(patches || []) }); + return patches; + }, + simStep: (state, action, ctx) => { + const patches = simStepPatch(state, action, ctx); + patchLog.push({ phase: "simStep", actionType: action?.type || "", patches: stableStringify(patches || []) }); + return patches; + }, + }, + { storageDriver: createNullDriver() }, + ); +} + +function runScenario(store, seed) { + store.dispatch({ type: "SET_SEED", payload: { seed } }); + store.dispatch({ type: "GEN_WORLD", payload: {} }); + + const stateAfterGen = store.getState(); + const preset = getWorldPreset(stateAfterGen.meta.worldPresetId); + const range = getStartWindowRange(preset.startWindows.player, stateAfterGen.world.w, stateAfterGen.world.h); + const workerX = range.x0; + const workerY = range.y0; + + store.dispatch({ type: "PLACE_WORKER", payload: { x: workerX, y: workerY, remove: false } }); + store.dispatch({ type: "SET_UI", payload: { runPhase: "run_active" } }); + store.dispatch({ type: "TOGGLE_RUNNING", payload: { running: true } }); + + for (let i = 0; i < 6; i += 1) { + store.dispatch({ type: "SIM_STEP", payload: {} }); + } + + const signatureMaterial = store.getSignatureMaterial(); + return { + signature: store.getSignature(), + signatureMaterialHash: sha256Text(signatureMaterial), + }; +} + +const seed = "deterministic-patch-chain-seed"; +const patchLogA = []; +const patchLogB = []; + +const storeA = createInstrumentedStore(seed, patchLogA); +const storeB = createInstrumentedStore(seed, patchLogB); + +const endA = runScenario(storeA, seed); +const endB = runScenario(storeB, seed); + +assert.deepEqual(patchLogA, patchLogB, "same seed + same action flow must emit identical reducer/simStep patch sequence"); +assert.equal(endA.signature, endB.signature, "same seed + same action flow must preserve final signature"); +assert.equal( + endA.signatureMaterialHash, + endB.signatureMaterialHash, + "same seed + same action flow must preserve signature material hash", +); + +console.log(`DETERMINISTIC_PATCH_CHAIN_OK events=${patchLogA.length} signature=${endA.signature} material=${endA.signatureMaterialHash}`); diff --git a/tests/test-runtime-boundaries.mjs b/tests/test-runtime-boundaries.mjs index 6392305..b66d81c 100644 --- a/tests/test-runtime-boundaries.mjs +++ b/tests/test-runtime-boundaries.mjs @@ -31,6 +31,8 @@ const runtimeFiles = [ ...collectJsFiles("src/app"), ...collectJsFiles("src/game"), ]; +const simFiles = collectJsFiles("src/game/sim"); +const uiFiles = collectJsFiles("src/game/ui"); const kernelFiles = collectJsFiles("src/kernel"); @@ -59,6 +61,24 @@ for (const relPath of runtimeFiles) { } } +for (const relPath of simFiles) { + const text = fs.readFileSync(path.join(root, relPath), "utf8"); + assert.equal( + /from\s+["']\.\.\/\.\.\/runtime\//.test(text) || text.includes("/game/runtime/"), + false, + `${relPath} must not import game/runtime from sim`, + ); +} + +for (const relPath of uiFiles) { + const text = fs.readFileSync(path.join(root, relPath), "utf8"); + assert.equal( + /from\s+["']\.\.\/sim\//.test(text) || text.includes("/game/sim/"), + false, + `${relPath} must not import sim modules directly from UI`, + ); +} + for (const relPath of kernelFiles) { const text = fs.readFileSync(path.join(root, relPath), "utf8"); assert.equal( @@ -73,6 +93,16 @@ for (const relPath of kernelFiles) { `${relPath} must not reference llm/orchestrator boundary '${needle}'`, ); } + const browserGlobalRef = + /typeof\s+localStorage\b/.test(text) + || /localStorage\./.test(text) + || /typeof\s+document\b/.test(text) + || /document\./.test(text); + assert.equal( + browserGlobalRef, + false, + `${relPath} must stay platform-neutral and avoid browser globals`, + ); } assert.equal(fs.existsSync(path.join(root, "src", "project")), false, "src/project must be removed after canonical game migration");