Compare commits

..

2 Commits

Author SHA1 Message Date
Rijad Zuzo 6aae41d36f docs: add graph-backed agent guide 2026-08-31 00:09:36 +02:00
Rijad Zuzo 52307f1306 feat: integrate regional caravan persistence 2026-08-23 17:20:41 +02:00
25 changed files with 2117 additions and 110 deletions
+178 -18
View File
@@ -3,11 +3,58 @@
This repository is a Godot 4.7 simulation prototype. Treat it as a living
systems project, not a content dump. The expected working style is:
## Start here and sources of truth
Begin with `docs/README.md`, then follow it to `docs/DEVELOPER_INDEX.md`. The
developer index is the shortest current map from a feature request to its code,
focused contract, tests, and known incomplete work.
When sources disagree, use this order:
1. Current code and focused tests establish implemented behavior; they do not
silently supersede an architectural decision.
2. Applicable Accepted records in `docs/decisions/` define durable architecture
within their stated scope until another Accepted record explicitly
supersedes them. Treat conflicting code as drift to fix or document.
`docs/ARCHITECTURE_OVERVIEW.md` is the current ownership/dependency map.
3. `docs/DEVELOPER_INDEX.md` and the focused architecture/schema guides define
the current documented contract.
4. `docs/LEARNING_ROADMAP.md` defines sequencing, not runtime truth.
Fix the smallest authoritative document after changing behavior. Do not create
a second implementation in prose or copy the same status table into many docs.
## Codebase discovery with codebase-memory
This repository is indexed as
`Users-rijadzuzo-dev-private-gamedev-the-steward` in codebase-memory. Treat the
name as a lookup hint, not proof that the graph is current.
- At session start, after a pull, and after changing branches, call
`list_projects` and `index_status`. Confirm the indexed root, branch, and HEAD
match the checkout; re-index after a large or external update.
- Use Verify/Tier 2 evidence by default: `search_graph` to find exact symbols,
`trace_path` in the material direction, and `get_code_snippet` for source.
Use `get_architecture` only for orientation, not as a substitute for exact
code.
- Check `has_more`/cursors and paginate relevant results. After candidate files
are known, call `check_index_coverage` once with every evidence path.
- A clean coverage result is best-effort, not proof of completeness. Read exact
source for partial, skipped, excluded, stale, pending, or unknown ranges.
- Use `rg` for string literals, resource paths, scene/config files, generated
data, and graph coverage gaps. Do not make negative or exhaustive claims from
a provisional graph search.
- Before a refactor, use `detect_changes` or inbound traces to inspect the blast
radius. Re-run focused searches after edits rather than relying on this file
as a frozen graph dump.
## Default development loop
1. Read the relevant roadmap/docs before changing code.
- Start with `docs/README.md`.
- Use `docs/LEARNING_ROADMAP.md` for the current next item.
- Start with `docs/README.md` and `docs/DEVELOPER_INDEX.md`.
- Use `docs/DEVELOPER_INDEX.md` for current status and open gaps.
- Use `docs/LEARNING_ROADMAP.md` for milestone sequencing and exit tests;
reconcile any candidate slice with current code and focused tests.
- Use focused plans such as `docs/RESOURCE_NODE_MIGRATION.md` only within
their stated scope.
2. Inspect the current code and tests before assuming roadmap status is still
@@ -37,7 +84,7 @@ Use the project quality gate for the current platform:
powershell -ExecutionPolicy Bypass -File .\tools\quality.ps1
```
For changed-file quick checks:
To narrow formatter/linter scope to changed GDScript files:
```bash
./tools/quality.sh --changed
@@ -47,18 +94,69 @@ For changed-file quick checks:
powershell -ExecutionPolicy Bypass -File .\tools\quality.ps1 -Changed
```
`--changed` narrows only the `gdformat` and `gdlint` file set. It still runs the
Godot import/parser check, every headless scenario, the compatibility-renderer
scenario, and GUT. Use it only while iterating; run the full current-platform
gate before every commit.
The quality scripts isolate Godot's user profile under `logs/quality/godot_profile`
so headless Godot 4.7 can run without crashing when platform user-data paths
are unavailable. Do not remove that behavior.
Useful extra checks:
```powershell
```bash
git diff --check
```
If touching `main.tscn` or runtime wiring, also do a direct Godot 4.7 headless
boot when practical.
If changing `project.godot`, `main.tscn`, autoload/plugin configuration, scene
UIDs or node paths, or startup/runtime scene wiring, also run the configured
main scene with the same Godot 4.7 binary used by the gate:
```bash
"$GODOT_BIN" --headless --path "$PWD" --quit-after 3
```
```powershell
& $env:GODOT_BIN --headless --path (Get-Location).Path --quit-after 3
```
Set `GODOT_BIN` when the gate auto-detected a binary instead. Inspect output as
well as exit status, and accept only diagnostics matching the quality gate's
exact allowlist. Read failures from `logs/quality/latest/`; do not weaken an
allowlist or skip a failing scenario merely to make the gate green.
## Current codebase map
Keep this map concise; `docs/DEVELOPER_INDEX.md` owns the detailed feature
matrix and extension workflow.
- `project.godot` starts `main.tscn`. The main scene wires the authored Jajce
world, player, `ActiveWorldAdapter`, `SimulationManager`,
`SaveSlotController`, `WorldViewManager`, and UI surfaces.
- `simulation/SimulationManager.gd` is the scene-tree facade and deterministic
tick boundary. It advances regional authority before committing the local
tick, then coordinates focused systems rather than duplicating their rules.
- `simulation/actions/`, `simulation/commands/`, and `simulation/economy/` own
selection/execution, target resolution, shared player/NPC command contracts,
and exact inventory or storage transactions.
- `simulation/events/`, `simulation/knowledge/`, `simulation/relationships/`,
`simulation/opportunities/`, `simulation/situations/`,
`simulation/dialogue/`, and `simulation/quests/` turn completed mutations
into causal facts and derived player-facing projections.
- `simulation/state/` owns versioned primitive records;
`simulation/definitions/` owns immutable definitions and stable IDs;
`simulation/regional/` owns scheduled distant work and caravan authority;
`simulation/persistence/` owns combined manifests and safe slot replacement.
- `world/active_world_adapter.gd` and `world/targets/` own contextual loaded-
world discovery, descriptors, capabilities, and transient handles. Resource,
storage, activity, and animal nodes are providers bound to simulation state.
- `world/world_view_manager.gd`, `world/presentation/`, `world/jajce/`,
`world/ui/`, and `player/` own input, geometry, navigation, and visible
feedback. They submit commands or report facts; they do not author saves.
- `tests/` contains deterministic headless scenarios and GUT tests;
`simulation/benchmark/` and `docs/benchmarks/` contain reproducible workload
evidence; `tools/` contains the cross-platform quality gate.
## Architecture direction
@@ -73,12 +171,29 @@ Preserve the simulation/presentation boundary.
`ActionTargetResolver`.
- Visual movement belongs to `WorldViewManager`/`NpcVisual`; decision and task
execution belong to the simulation systems.
- Player and NPC callers use the same commands and capabilities. Revalidate the
actor, target, context, range, revision, cost, and permission at execution;
mutate atomically before recording facts or deriving projections.
- Target handles, generation tokens, spatial/population indexes, presentation
cues, and reason traces are disposable derived state. Persist stable
contextual IDs and primitives, then prove rebuilding derived state preserves
decisions and checksums.
- A local tick may advance only after the regional facade succeeds. Regional
work must remain deterministic, bounded, fail-closed, and rollback-safe.
- When regional authority exists, save and restore must use the combined
manifest and remain all-or-nothing. Preserve schema validation, size bounds,
restore rollback, temporary-file validation, and the atomic
temporary/backup/final replacement path; a failed restore emits no success
notification.
When adding a system, first prove the contract with one real gameplay use case.
Do not extract generic frameworks before multiple real consumers justify them.
## Emergent-world doctrine
These agent-facing guardrails summarize ADR 0001 and ADR 0002. The Accepted
records remain authoritative within their stated scopes.
The game world is the source of narrative truth. Dialogue, tasks, quests, and
visible happenings must arise from ordinary simulation state and events rather
than maintaining parallel scripted copies.
@@ -134,14 +249,16 @@ behaviour, not as a one-off object. Add a new type through data/definitions
plus a small behaviour or visual hook, reusing the root for movement,
presentation, serialization, and lifecycle:
- resources: `ResourceNode` + `ResourceStateRecord` (amount, yield, regrowth);
- creatures: `CreatureVisual` shared movement root (navigate to the
authoritative simulation position, report position, death), with per-type
visuals built from definitions;
- enemies: `EnemyDefinition` + `SimulationEnemies` registry driving combatant
spawns and hostile visuals;
- animals: `AnimalNode` + `AnimalStateRecord` following the same
follow-the-authoritative-position contract.
- resources: immutable resource definitions plus authoritative
`ResourceStateRecord`; `ResourceNode` is the loaded provider and visual
binding;
- creatures: `CreatureVisual` is the shared navigation/death presentation root;
- enemies: `EnemyDefinition`/`SimulationEnemies` provide typed content,
`CombatantFactory` and `ConflictSystem` own authoritative instances and
lifecycle, and `HostileCombatant` presents them;
- animals: `AnimalDefinition`, `AnimalStateRecord`, `AnimalFactory`, and
`AnimalCareSystem` own content and lifecycle; `AnimalNode` is the loaded
interaction/navigation binding.
A new berry, tree, bear, bandit, boar, or goat should be mostly a definition
plus a bounded hook — never a new movement/combat/save system.
@@ -159,6 +276,19 @@ Resource additions should preserve:
Storage uses `StorageNode`. Rest, study, and patrol use `ActivitySite`. Do not
turn these back into generic marker zones.
## Vendored dependencies and Godot asset hygiene
- Treat `addons/dialogue_manager`, `addons/gut`, and `addons/terrain_3d` as
pinned vendor code. Do not reformat, refactor, or update them unless the task
explicitly owns that dependency.
- Preserve the complete cross-platform Terrain3D payload checked by the quality
gate. Do not replace a missing platform binary with a local-only artifact.
- Preserve `.uid` files, `class_name` locations, resource UIDs, and scene paths
when moving Godot scripts or scenes. Use `git mv` for intentional moves and
prove them with import plus headless startup.
- Never commit `.godot/`, `.venv/`, `logs/quality/`, or generated local editor
state. Do not hand-edit imported cache files to hide an error.
## Testing expectations
Add or update headless scenarios when a change affects:
@@ -169,19 +299,34 @@ Add or update headless scenarios when a change affects:
- navigation/reachability assumptions;
- player/NPC parity;
- deterministic continuation;
- regional job ordering, rollback, or local/regional tick coordination;
- combined-manifest save/restore, migration, or atomic slot behavior;
- event causality, knowledge, situation, dialogue, or quest projections;
- UI/debug state that represents real simulation facts.
Keep tests deterministic. Prefer fixed seeds and stable IDs.
Any persistent-state change requires an explicit schema/migration decision,
exact validation, checksum and tamper coverage, rollback coverage, and a
deterministic continuation test.
## Documentation expectations
Update docs when the implemented behavior changes the roadmap, architecture, or
current contract. Keep updates local:
- `docs/DEVELOPER_INDEX.md` for the current feature map, extension routes, and
deliberately incomplete work;
- `docs/ARCHITECTURE_OVERVIEW.md` for ownership and dependency boundaries;
- `docs/PROJECT_CONTEXT.md` only when product intent or active constraints
change; dated implementation passages are historical context;
- `docs/LEARNING_ROADMAP.md` for next-item sequencing and milestone status;
- `docs/RESOURCE_NODE_MIGRATION.md` for resource-target migration status;
- `docs/SIMULATION_STATE_SCHEMA.md` for serialized state changes;
- `docs/SIMULATION_DEFINITIONS.md` for action/profession/ID contracts;
- `docs/ACTION_SYSTEM_ARCHITECTURE.md`, `docs/ECONOMIC_EVENTS.md`, and
`docs/REGIONAL_SIMULATION.md` for their focused runtime contracts;
- `docs/FEATURE_*.md` only when the corresponding implementation/extension
guide changes;
- `docs/BUILD_IN_PUBLIC_PLAN.md` for Jajce/demo/readability slices;
- `docs/decisions/` only for durable architectural decisions.
@@ -190,16 +335,31 @@ trust code/tests first, then update docs.
## Git expectations
Preserve user changes. Check `git status --short` before editing and before
committing. Do not use destructive cleanup commands unless explicitly asked.
Preserve user changes. Check `git status --short --branch` before editing and
before committing, inspect the branch/upstream and recent history, and stage
only the files owned by the current slice. Do not use destructive cleanup
commands unless explicitly asked.
Use conventional commits, for example:
Every repository commit must use Conventional Commits with an imperative,
specific summary: `<type>(optional-scope): summary`. Common types are `feat`,
`fix`, `docs`, `test`, `refactor`, `perf`, `build`, `ci`, `chore`, and `merge`.
Examples:
- `feat: validate expanded resource discovery`
- `fix: stabilize godot 4.7 headless validation`
- `docs: capture agent workflow`
Commit only after validation relevant to the change has passed.
Before committing, review `git diff`, run `git diff --check` and the full
current-platform quality gate, then review `git diff --cached`. Validation is
explicit; do not assume a local hook ran it. One coherent vertical slice may
include its implementation, deterministic tests, required `.uid`/resource
files, and smallest necessary documentation update. Split unrelated fixes,
never stage pre-existing user work, and never use `--no-verify` to bypass a
repository gate.
Do not amend, rebase, drop, or fold user-owned commits unless explicitly asked.
Do not push implicitly. After committing, report the commit hash and the real
ahead/behind or remote-sync state.
## Product taste
+13 -12
View File
@@ -21,10 +21,11 @@ own persistent quantities, travel completion, or quest progress.
The long-term direction is one regional simulation containing settlements,
routes, caravans, named people, aggregate cohorts, and polities. The current
repository has the regional records, deterministic scheduler, one exact
caravan transfer service, chunked persistence, and a 20-caravan structural
benchmark. It does not yet run that regional service as the production
`SimulationManager` facade or provide a complete market, settlement-growth, or
kingdom gameplay loop. That distinction is important when extending the game.
caravan transfer service, a production facade for one inbound Jajce route, a
combined local/regional save manifest, chunked persistence, and a 20-caravan
structural benchmark. It does not yet provide a complete market,
settlement-growth, or kingdom gameplay loop. That distinction is important
when extending the game.
## Start here
@@ -53,9 +54,9 @@ kingdom gameplay loop. That distinction is important when extending the game.
| Situations/quests | Definition-driven bounded situations, derived progress, journal metadata, commitments and idempotent outcomes | The initial Jajce slice is bounded; it is not a universal objective scripting language |
| Dialogue | Deterministic semantic `ConversationService`, authored intent/template resources, Dialogue Manager v3 presenter | Dialogue Manager renders planned turns; it is not an authority or save format |
| Entities | Shared definition → instance/state → presentation contracts for resources, animals, enemies, items, storage | New behavior profiles and genuinely new mechanics still need typed code and tests |
| Regional kernel | Primitive records, due-job heap, keyed random, analytical updates, caravan proof, chunk/file store | Regional service and 20-caravan trade are currently integration fixtures, not the live Jajce loop |
| Regional kernel | Primitive records, due-job heap, keyed random, analytical updates, caravan service, production facade, chunk/file store | One authored inbound route is live; the 20-caravan economy remains a structural fixture, not the Jajce market |
| Presentation | Active-world adapters, context registries, relevance cap of 40, navigation budget of 2/frame, High/Balanced/Low profiles | Hardware frame-time/VRAM evidence is still pending; structural benchmarks are not rendered claims |
| Persistence | Local save schema v16 with migrations and scope preflight; regional chunk generations with recovery | No save menu/profile system; regional chunk store is not yet the local `SaveSlotStore` backend |
| Persistence | Combined manifest v1 containing unchanged local schema v16 plus the canonical regional facade; regional chunk generations with recovery | No save menu/profile system; regional chunk/history generations are not yet the `SaveSlotStore` backend |
## The authority pipeline
@@ -106,9 +107,10 @@ relationship, or reward that the event claims happened.
- `simulation/events/`, `knowledge/`, `relationships/`, `situations/`, and
`dialogue/` — causal history and emergent narrative layers.
- `simulation/state/` — versioned primitive records and validation.
- `simulation/persistence/`local quicksave safety.
- `simulation/regional/` — regional records, scheduler, caravan proof, chunk
codec, and file generations.
- `simulation/persistence/`combined quicksave manifest and atomic slot
replacement, plus regional chunk codecs and generations.
- `simulation/regional/` — production facade, regional records, scheduler,
caravan transaction, retention planning, and scale contracts.
### Loaded world and presentation
@@ -161,12 +163,11 @@ are used when an existing save is loaded.
The following are deliberately **not** claimed as complete:
- wire `RegionalCaravanService` into the production regional facade and local
save manifest;
- integrate event retention/rollups/chunked history into one atomic,
lazily loaded long-horizon save;
- run 20 real caravan trades across five settlement economies with atomic
cargo/stock ledgers;
- replace local unloaded travel freezes with authoritative route-time work;
- integrate event retention/rollups/chunked history into one long-horizon save;
- add settlement projects, cohort promotion/demotion, offices, diplomacy,
armies, campaigns, and aggregate conflict;
- add visual relevance streaming to the live world view rather than only the
@@ -2,9 +2,9 @@
The regional package is the scale foundation for one authority spanning
settlements, routes, caravans, named people, aggregate cohorts, and polities.
It is intentionally additive: the current Jajce `SimulationManager` remains
the playable local facade while the regional contracts prove deterministic
unloaded work and conservation in isolation.
The production `SimulationManager` now owns a bounded regional facade for one
inbound Jajce route, while the broader regional contracts continue to prove
deterministic unloaded work and conservation before wider integration.
## Regional record model
@@ -63,6 +63,12 @@ no partial mutation. `to_dictionary()`, `from_dictionary()`, and `checksum()`
provide deterministic save/restore. The test runs always-loaded, never-loaded,
and load/unload presentation modes through the same authoritative service.
`RegionalSimulationFacade` is the first production consumer. It owns one
authored Travnik-to-Jajce route, advances before each committed local tick, and
exposes one bounded delivery command. The cargo is a separate catalogued
`regional_trade_goods` item with no local storage tag, so this proof does not
shadow or duplicate the pantry and woodpile economy.
## Chunked persistence
`RegionalChunkedPersistence` captures the service into a manifest plus fixed
@@ -89,11 +95,20 @@ primitive-tree bounds.
## Current integration boundary
The regional package is not yet the production authority for `main.tscn`:
`main.tscn` now advances the regional facade and `SaveSlotStore` writes a
combined `SimulationSaveManifest` containing the unchanged local v16 record
and canonical regional envelope. Both checksums plus world, seed, location,
and tick alignment are validated before either authority is restored. Raw
local v16 slots remain compatible and create an idle regional facade;
explicit non-Jajce adapter scopes keep writing local v16 until they have a
matching regional facade. Those scopes cannot dispatch a Jajce delivery, and
local-only saving fails closed if regional state is no longer reconstructibly
idle.
- Jajce local saves still use `SaveSlotStore` and `SimulationStateRecord` v16;
- `RegionalCaravanService` is a complete isolated transfer proof, not the live
economy's 20-trade scheduler;
The remaining boundary is intentionally narrow:
- the facade has one callable inbound route, not the live economy's 20-trade
market scheduler;
- event retention/rollups and regional chunk files are not yet one integrated
long-horizon save transaction;
- loaded/unloaded local NPC travel still has the old active-visual boundary;
@@ -101,8 +116,8 @@ The regional package is not yet the production authority for `main.tscn`:
aggregate conflict remain planned.
Do not create one `SimulationManager` per settlement or caravan. The next
production step is an explicit regional facade that delegates to these records
and services, while the active adapter presents only a bounded relevant context.
production step is to publish retained raw events, daily rollups, and chunked
regional history as one atomic, lazily loaded transaction behind the facade.
## Scale evidence
+20 -8
View File
@@ -64,11 +64,22 @@ catch corruption.
## Local file safety
`SaveSlotStore` validates the complete JSON before replacement, restricts slot
names and size, preserves a previous file, and can recover a backup after an
interrupted replacement. `F5`/`F9` use the current quicksave path in `main.tscn`.
The player transform and presentation-only scene state are intentionally not
saved.
`SimulationSaveManifest` v1 combines the unchanged local v16 record and the
canonical `RegionalSimulationFacade` envelope. It stores an independent
checksum for each authority and cross-validates world, seed, active location,
and processed tick. A rejected manifest cannot partially replace local or
regional state.
`SaveSlotStore` validates the complete manifest before replacement, restricts
slot names and size, preserves a previous file, and can recover a backup after
an interrupted replacement. It also accepts existing raw local v16 files and
bootstraps an idle regional facade at the restored seed and tick. Explicit
non-Jajce adapter scopes continue writing raw local v16 records until a
matching regional facade is implemented. This fallback is permitted only while
the regional facade equals its deterministic idle bootstrap; a local-only save
fails closed rather than discarding live regional state. `F5`/`F9` use the
current quicksave path in `main.tscn`. The player transform and presentation-only
scene state are intentionally not saved.
## Regional persistence boundary
@@ -79,9 +90,10 @@ per-file checksums. The file store uses immutable generations, a current
pointer, recovery scanning, and conservative pruning. Partial active-location
loads are metadata-only and cannot silently resurrect a removed location.
This regional codec is not yet the backend for local `SaveSlotStore`. Integrate
it only after the production regional facade, event retention, and lazy context
loading have one atomic manifest contract.
This regional chunk codec is not yet the backend for `SaveSlotStore`. The
production facade and combined quicksave manifest are complete; retained raw
events, rollups, chunk generations, and lazy context loading still need one
atomic long-horizon publication contract.
## How to change state safely
+1 -1
View File
@@ -77,7 +77,7 @@ For a new system, add tests for:
## Current evidence and gaps
The current full suite passes 154/154 tests with 2,047 assertions. Baseline 03
The current full suite passes 180/180 tests with 2,258 assertions. Baseline 03
measures 600 full-fidelity data-only NPC/combatant records at about 90.6 ticks/s
on the reviewed development host. The regional baseline proves the target
record counts and scheduler parity. Neither measures a complete regional
+11 -4
View File
@@ -1,10 +1,11 @@
# The Steward — Learning and Reusable-Systems Roadmap
> **Current implementation checkpoint (2026-08-13):** The roadmap below is a
> **Current implementation checkpoint (2026-08-23):** The roadmap below is a
> chronological learning record. The current code has completed the authored
> content/catalog, shared target/command, event/social, situation/dialogue,
> entity-family, context-scoping, and first regional record/caravan/chunk
> slices. For a source-based status matrix and the remaining integration work,
> slices, including one production facade and combined save manifest. For a
> source-based status matrix and the remaining integration work,
> start with [`DEVELOPER_INDEX.md`](DEVELOPER_INDEX.md). Do not infer current
> behavior from an older milestone paragraph when the index or code disagrees.
@@ -43,8 +44,8 @@ becomes knowledge, generated semantic dialogue, a journal projection, and an
event-backed social commitment whose ordinary deposit outcome survives
save/load. Definition packs now cover reusable items, resources, storage,
enemies, and animals. Regional records, deterministic scheduled work, bounded
presentation relevance, and the first authoritative caravan transfer form the
next world-slice foundation. See
presentation relevance, and the first authoritative caravan transfer now feed
one production facade and combined local/regional save boundary. See
[ADR 0001](decisions/0001-simulation-authority-boundary.md),
[ADR 0002](decisions/0002-emergent-world-content-and-narrative.md), and the
[regional simulation contract](REGIONAL_SIMULATION.md).
@@ -1107,6 +1108,12 @@ resolution, and personal request accept/decline.
Recently completed:
- `Regional Jajce Facade 13`: `SimulationManager` now advances one authored
Travnik-to-Jajce caravan route through the regional service, and an atomic
combined manifest preserves both local v16 and regional authority. Separate
regional trade goods prevent duplicate pantry/woodpile ownership; tamper,
legacy-slot, rollback, conservation, and deterministic continuation
regressions define the boundary before broader markets or chunked history.
- `Jajce Villager Field Note 12`: a separate player-facing note selects the
nearest loaded living villager and shows live name, action, task state, exact
target, carried inventory, and a currently matching decision reason. It
+37 -16
View File
@@ -1,10 +1,11 @@
# Regional simulation contract
> **Implementation status (2026-08-13):** The record, scheduler, caravan,
> **Implementation status (2026-08-23):** The record, scheduler, caravan,
> chunk, file-generation, relevance, and navigation-budget contracts are
> implemented and tested. They are still an additive regional package; the
> playable Jajce `SimulationManager` and `SaveSlotStore` remain the local
> production authority until the integrations listed at the end land.
> implemented and tested. `SimulationManager` now owns a production regional
> facade for one inbound Jajce route, and `SaveSlotStore` persists local and
> regional authority in one checked manifest. The scale economy and chunked
> long-horizon history remain separate integrations.
The regional layer extends the Jajce simulation without multiplying scene-tree
managers. One authority owns every loaded and unloaded location, settlement,
@@ -38,12 +39,10 @@ validated transactionally before a replacement state is published. Canonical
serialization sorts record families and IDs, making checksums independent of
construction order.
The current local `SimulationStateRecord` remains the v16 Jajce save boundary.
Regional state is deliberately carried in its own versioned service envelope
until one playable route consumes it; this avoids placing speculative regional
fields in every local save. The merge into the production save manifest must
retain this canonical regional payload rather than copying its fields into
scene nodes.
The current local `SimulationStateRecord` remains the unchanged v16 Jajce
record. `SimulationSaveManifest` v1 combines that record with the canonical
`RegionalSimulationFacade` envelope and checksums both authorities. Regional
fields are not copied into local records or scene nodes.
## Deterministic work
@@ -92,6 +91,30 @@ never-loaded, and load/unload executions must preserve cargo, headcount, facts,
arrival tick, queue state, and checksum. Nearby visuals may interpolate route
progress; they do not report elapsed travel completion.
## Production facade and combined save
`RegionalSimulationFacade` is now owned by the production `SimulationManager`.
It bootstraps an authored Travnik-to-Jajce route and advances the same
`RegionalCaravanService` before each committed local tick. Its bounded public
command can depart one exact delivery; arrival remains scheduled simulation
work and does not depend on a loaded caravan visual.
The route transports `regional_trade_goods`, a catalogued item with no local
storage tag. Jajce starts with no regional stock and Travnik starts with four
units, so the facade cannot duplicate the playable pantry's food or the
woodpile's wood. Local and regional event-ID spaces remain deliberately
separate until an explicit cross-domain history contract exists.
`SimulationSaveManifest` stores exactly the local v16 record, its checksum, the
regional facade envelope, its checksum, and the manifest schema/version. It
rejects checksum tampering and mismatched world, seed, active location, or
tick. Combined restore is transactional across both authorities. Existing raw
local v16 quicksaves remain loadable and bootstrap an idle regional route at
the restored seed and tick. Explicit non-Jajce adapter scopes continue to use
their local v16 boundary until a matching regional facade exists. That fallback
is allowed only while the regional facade equals its deterministic idle
bootstrap; Jajce delivery commands are rejected outside the combined scope.
## Event retention and daily rollups
`WorldEventRetentionPlanner` is a pure, deterministic query over a complete
@@ -157,14 +180,12 @@ active-world and hardware captures.
Build in this order, keeping each step behind conservation and continuation
tests:
1. make the Jajce route proof callable from the production regional facade and
save manifest;
2. integrate the retention/rollup and chunk codecs as one atomic,
1. integrate the retention/rollup and chunk codecs as one atomic,
lazily loaded history transaction;
3. schedule 20 ordinary caravan trades across five settlement economies;
4. promote/demote presentation and population fidelity while pinning named and
2. schedule 20 ordinary caravan trades across five settlement economies;
3. promote/demote presentation and population fidelity while pinning named and
causally referenced people;
5. add data-defined settlement projects, tier promotion, offices, diplomacy,
4. add data-defined settlement projects, tier promotion, offices, diplomacy,
armies, campaigns, and aggregate conflict.
Context-owned world-target registries and the first shared player/NPC activity
+16 -8
View File
@@ -451,12 +451,20 @@ replayed after restore.
## Local quicksave boundary
`SaveSlotStore` writes the existing versioned local JSON record to
`user://saves/quicksave.json`. It validates the serialized record before
replacement, limits file size, restricts slot names, preserves the previous
file during replacement, and can recover that backup if replacement is
interrupted. Loading parses and validates the complete record before mutating
the simulation.
`SaveSlotStore` writes `SimulationSaveManifest` v1 to
`user://saves/quicksave.json`. The manifest contains the unchanged local v16
record and canonical regional facade envelope, with an independent checksum
for each. Restore also requires world, seed, active location, and tick to agree
across both authorities, and rolls both back if local restoration fails.
The store validates the complete serialized manifest before replacement,
limits file size, restricts slot names, preserves the previous file during
replacement, and can recover that backup if replacement is interrupted. Raw
local v16 quicksaves remain loadable; they bootstrap an idle regional facade
at the restored seed and tick. Explicit non-Jajce adapter scopes continue
writing local v16 records until a matching regional facade exists. Local-only
saving is allowed only while regional state equals its deterministic idle
bootstrap; otherwise it fails closed rather than discarding authority.
In `main.tscn`, F5 saves and F9 loads this slot. Restoration rebuilds active NPC
visuals from authoritative state.
@@ -476,8 +484,8 @@ This phase does not yet provide:
- persistence of a currently open dialogue UI/turn. Semantic conversation
history and commitment authority persist, while a balloon is regenerated
after restore;
- production integration of the regional chunked save manifest, lazy location
loading, or aggregate population promotion/persistence;
- production integration of regional retained/rolled-up chunk history, lazy
location loading, or aggregate population promotion/persistence;
- persistence for the player transform or presentation-only scene state.
Those features should build on this boundary rather than inventing parallel
+116 -1
View File
@@ -12,6 +12,9 @@ const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
const ConflictSystemScript := preload("res://simulation/conflict/ConflictSystem.gd")
const RegionalSimulationFacadeScript := preload(
"res://simulation/regional/RegionalSimulationFacade.gd"
)
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
signal village_changed(village: SimVillage)
@@ -84,6 +87,8 @@ var conflict_system := ConflictSystemScript.new()
var player_quest_system := PlayerQuestSystem.new()
var player_needs := PlayerNeedsSystem.new()
var player_negotiation := PlayerNegotiationSystem.new()
var _regional_simulation: RegionalSimulationFacade
var _restoring_save_manifest := false
var _last_player_tier := PlayerStandingRecord.TIER_STRANGER
var storage_states: Dictionary:
get:
@@ -165,6 +170,9 @@ func _ready() -> void:
clock = SimulationClock.new(tick_interval)
clock.cycle_duration_seconds = cycle_duration_seconds
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
if not _initialize_regional_simulation(tick_count):
set_process(false)
return
village.debug_logs = debug_logs
economy.configure(village, debug_logs)
animal_care.configure(economy, active_world_adapter)
@@ -248,7 +256,17 @@ func get_wander_offset(npc_id: int) -> Vector3:
func simulate_tick() -> void:
tick_count += 1
var next_tick := tick_count + 1
if _regional_simulation == null or not _regional_simulation.advance_to_tick(next_tick):
var regional_error := (
_regional_simulation.get_last_error()
if _regional_simulation != null
else "regional facade is missing"
)
push_error("SimulationManager: regional tick failed: " + regional_error)
set_process(false)
return
tick_count = next_tick
if debug_logs:
print("--- Tick ", tick_count, " ---")
animal_care.advance(tick_count)
@@ -2408,6 +2426,34 @@ func get_state_checksum() -> String:
return create_state_record().to_json().sha256_text()
func get_regional_state_checksum() -> String:
return _regional_simulation.checksum() if _regional_simulation != null else ""
func get_world_state_checksum() -> String:
var manifest := create_save_manifest()
return manifest.checksum() if manifest != null else ""
func get_regional_snapshot() -> Dictionary:
return (
_regional_simulation.to_dictionary().duplicate(true) if _regional_simulation != null else {}
)
func request_regional_jajce_delivery(
amount: float = 1.0, presentation_mode: StringName = &"never"
) -> bool:
if (
not uses_combined_save_manifest()
or _regional_simulation == null
or not is_finite(amount)
or amount <= 0.0
):
return false
return _regional_simulation.depart_jajce_delivery(amount, presentation_mode)
func get_latest_decision(npc_id: int) -> ActionSelectionResult:
return latest_decisions.get(npc_id)
@@ -2484,6 +2530,59 @@ func serialize_state() -> String:
return create_state_record().to_json()
func uses_combined_save_manifest() -> bool:
var scope := event_log.get_scope()
return (
StringName(scope.get("world_id", &"")) == SimulationIds.REGIONAL_WORLD_BOSNIA
and (StringName(scope.get("location_id", &"")) == SimulationIds.REGIONAL_LOCATION_JAJCE)
)
func can_serialize_local_state_only() -> bool:
if uses_combined_save_manifest() or _regional_simulation == null:
return false
var idle_facade := RegionalSimulationFacadeScript.create_jajce_route(
simulation_seed, tick_count
)
return idle_facade != null and idle_facade.checksum() == _regional_simulation.checksum()
func create_save_manifest() -> SimulationSaveManifest:
if _regional_simulation == null:
return null
return SimulationSaveManifest.create(create_state_record(), _regional_simulation)
func serialize_save_manifest() -> String:
var manifest := create_save_manifest()
return manifest.to_json() if manifest != null else ""
func restore_save_manifest(manifest: SimulationSaveManifest) -> bool:
if manifest == null:
return false
var local_candidate := manifest.get_local_state()
var regional_candidate := manifest.get_regional_facade()
if local_candidate == null or regional_candidate == null:
return false
var previous_local := create_state_record()
var previous_regional := _regional_simulation
_regional_simulation = regional_candidate
_restoring_save_manifest = true
var restored := restore_state(local_candidate)
_restoring_save_manifest = false
if restored:
_publish_restored_state()
return true
_regional_simulation = previous_regional
_restoring_save_manifest = true
var rolled_back := restore_state(previous_local)
_restoring_save_manifest = false
if not rolled_back:
push_error("SimulationManager: failed to roll back rejected save manifest")
return false
func restore_state_from_json(json_text: String) -> bool:
var record := SimulationStateRecord.from_json(json_text)
if record == null:
@@ -2578,8 +2677,24 @@ func restore_state(record: SimulationStateRecord) -> bool:
conflict_system.register_npc_combatants(npcs)
conflict_system.configure(economy, tick_interval)
_sync_player_combatant()
if not _restoring_save_manifest and not _initialize_regional_simulation(tick_count):
return false
if not _restoring_save_manifest:
_publish_restored_state()
return true
func _publish_restored_state() -> void:
village_changed.emit(village)
state_restored.emit()
func _initialize_regional_simulation(current_tick: int) -> bool:
var facade := RegionalSimulationFacadeScript.create_jajce_route(simulation_seed, current_tick)
if facade == null:
push_error("SimulationManager: could not initialize the Jajce regional facade")
return false
_regional_simulation = facade
return true
+10
View File
@@ -8,6 +8,15 @@ const WORLD_CORE := &"world_core"
const LOCATION_JAJCE := &"settlement_jajce"
const REGIONAL_WORLD_BOSNIA := &"regional_bosnia"
const REGIONAL_LOCATION_JAJCE := &"location_jajce"
const REGIONAL_LOCATION_TRAVNIK := &"location_travnik"
const REGIONAL_SETTLEMENT_JAJCE := LOCATION_JAJCE
const REGIONAL_SETTLEMENT_TRAVNIK := &"settlement_travnik"
const REGIONAL_POLITY_JAJCE := &"polity_jajce"
const REGIONAL_POLITY_TRAVNIK := &"polity_travnik"
const REGIONAL_ROUTE_JAJCE_TRAVNIK := &"route_jajce_travnik"
const REGIONAL_CARAVAN_BLUE := &"group_blue_caravan"
const REGIONAL_PERSON_ANA := &"person_ana"
const REGIONAL_COHORT_CARRIERS := &"cohort_carriers"
const ACTIVITY_GUARD_POST := &"guard_post"
const ACTIVITY_STUDY_DESK := &"study_desk"
const ACTION_HANDLER_ACTIVITY_METRIC_DELTA := &"activity_metric_delta"
@@ -51,6 +60,7 @@ const TARGET_FREE := &"free"
const RESOURCE_FOOD := &"food"
const RESOURCE_WOOD := &"wood"
const RESOURCE_REGIONAL_TRADE_GOODS := &"regional_trade_goods"
const ANIMAL_DUNJA := &"goat_dunja"
const ANIMAL_ZORA := &"goat_zora"
@@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
item_id = &"regional_trade_goods"
display_name = "Regional Trade Goods"
category = &"resource"
+3 -2
View File
@@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=94 format=3]
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=95 format=3]
[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"]
@@ -92,6 +92,7 @@
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_broken_hostility.tres" id="89_broken_hostility"]
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_released_obligation.tres" id="90_released_obligation"]
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_superseded_obligation.tres" id="91_superseded_obligation"]
[ext_resource type="Resource" path="res://simulation/definitions/items/regional_trade_goods.tres" id="93_regional_trade_goods"]
[resource]
script = ExtResource("1_pack")
@@ -101,7 +102,7 @@ required_handler_ids = [&"activity_metric_delta"]
actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")]
professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")]
capability_tags = [ExtResource("28_item_food"), ExtResource("29_item_medicine"), ExtResource("30_item_wood"), ExtResource("31_resource_harvestable"), ExtResource("32_storage_village")]
items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood"), ExtResource("27_herb")]
items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood"), ExtResource("27_herb"), ExtResource("93_regional_trade_goods")]
resources = [ExtResource("33_berry_patch"), ExtResource("34_herb_patch"), ExtResource("35_tree")]
storages = [ExtResource("36_apothecary"), ExtResource("37_pantry"), ExtResource("38_woodpile")]
enemies = [ExtResource("24_raider"), ExtResource("25_wolf"), ExtResource("26_boar")]
+64 -20
View File
@@ -15,16 +15,36 @@ func _init(directory: String = DEFAULT_DIRECTORY) -> void:
func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
last_error = ""
if manager == null or not manager.has_method("serialize_state"):
return _fail("Save source cannot serialize simulation state")
if (
manager == null
or not (
manager.has_method("serialize_save_manifest") or manager.has_method("serialize_state")
)
):
return _fail("Save source cannot serialize authoritative state")
if not _is_valid_slot_name(slot_name):
return _fail("Invalid save slot name")
var json_text: String = manager.serialize_state()
var use_manifest := manager.has_method("serialize_save_manifest")
if use_manifest and manager.has_method("uses_combined_save_manifest"):
use_manifest = bool(manager.call("uses_combined_save_manifest"))
if (
not use_manifest
and manager.has_method("can_serialize_local_state_only")
and not bool(manager.call("can_serialize_local_state_only"))
):
return _fail("Save source has regional authority outside its active manifest scope")
var json_text: String
if use_manifest:
json_text = String(manager.call("serialize_save_manifest"))
elif manager.has_method("serialize_state"):
json_text = String(manager.call("serialize_state"))
else:
return _fail("Save source cannot serialize its active authority scope")
if json_text.to_utf8_buffer().size() > MAX_SAVE_BYTES:
return _fail("Save exceeds the supported size limit")
if SimulationStateRecord.from_json(json_text) == null:
return _fail("Simulation produced an invalid save record")
if not _is_valid_save_json(json_text):
return _fail("Simulation produced an invalid save payload")
if not _ensure_directory():
return false
@@ -39,7 +59,7 @@ func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
file.store_string(json_text)
file.flush()
file.close()
if _read_record(temporary_path) == null:
if _read_payload(temporary_path).is_empty():
_remove_if_present(temporary_path)
return _fail("Temporary save failed validation")
@@ -60,19 +80,29 @@ func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
func load_into(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
last_error = ""
if manager == null or not manager.has_method("restore_state"):
return _fail("Save target cannot restore simulation state")
if manager == null:
return _fail("Save target is missing")
if not _is_valid_slot_name(slot_name):
return _fail("Invalid save slot name")
var final_path := get_slot_path(slot_name)
var record := _read_record(final_path)
if record == null:
record = _read_record(final_path + ".bak")
if record == null:
var payload := _read_payload(final_path)
if payload.is_empty():
payload = _read_payload(final_path + ".bak")
if payload.is_empty():
return _fail("Save slot is missing, invalid, or unsupported")
if not bool(manager.restore_state(record)):
return _fail("Simulation refused the validated save record")
if payload.has("manifest"):
if not manager.has_method("restore_save_manifest"):
return _fail("Save target cannot restore a combined save manifest")
if not bool(manager.call("restore_save_manifest", payload["manifest"])):
return _fail("Simulation refused the validated save manifest")
elif payload.has("local_state"):
if not manager.has_method("restore_state"):
return _fail("Save target cannot restore legacy local state")
if not bool(manager.call("restore_state", payload["local_state"])):
return _fail("Simulation refused the validated legacy save record")
else:
return _fail("Save slot payload kind is unsupported")
return true
@@ -80,7 +110,10 @@ func has_slot(slot_name: String = DEFAULT_SLOT) -> bool:
if not _is_valid_slot_name(slot_name):
return false
var final_path := get_slot_path(slot_name)
return _read_record(final_path) != null or _read_record(final_path + ".bak") != null
return (
not _read_payload(final_path).is_empty()
or not _read_payload(final_path + ".bak").is_empty()
)
func delete_slot(slot_name: String = DEFAULT_SLOT) -> bool:
@@ -97,18 +130,29 @@ func get_slot_path(slot_name: String = DEFAULT_SLOT) -> String:
return save_directory.path_join(slot_name + ".json")
func _read_record(path: String) -> SimulationStateRecord:
func _read_payload(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return null
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return null
return {}
if file.get_length() > MAX_SAVE_BYTES:
file.close()
return null
return {}
var json_text := file.get_as_text()
file.close()
return SimulationStateRecord.from_json(json_text)
var manifest := SimulationSaveManifest.from_json(json_text)
if manifest != null:
return {"manifest": manifest}
var local_state := SimulationStateRecord.from_json(json_text)
return {"local_state": local_state} if local_state != null else {}
func _is_valid_save_json(json_text: String) -> bool:
return (
SimulationSaveManifest.from_json(json_text) != null
or SimulationStateRecord.from_json(json_text) != null
)
func _ensure_directory() -> bool:
@@ -0,0 +1,325 @@
class_name SimulationSaveManifest
extends RefCounted
const SCHEMA_NAME := "the_steward.save_manifest"
const SCHEMA_VERSION := 1
const MANIFEST_FIELDS := [
"schema",
"schema_version",
"local_state",
"local_checksum",
"regional_state",
"regional_checksum",
]
const MAX_SAFE_JSON_INTEGER := 9007199254740991.0
var _local_state: SimulationStateRecord
var _regional_facade: RegionalSimulationFacade
var _local_checksum := ""
var _regional_checksum := ""
static func create(
local_state: SimulationStateRecord, regional_facade: RegionalSimulationFacade
) -> SimulationSaveManifest:
if local_state == null or regional_facade == null:
return null
var local_copy := _canonicalize_local_state(local_state)
var regional_copy := _parse_regional_facade(regional_facade.to_dictionary())
if local_copy == null or regional_copy == null:
return null
return _assemble(local_copy, regional_copy)
static func from_dictionary(record_data: Dictionary) -> SimulationSaveManifest:
return _from_dictionary(record_data, false)
static func _from_dictionary(
record_data: Dictionary, allow_json_number_types: bool
) -> SimulationSaveManifest:
if not _has_exact_fields(record_data, MANIFEST_FIELDS):
return null
if (
not record_data["schema"] is String
or String(record_data["schema"]) != SCHEMA_NAME
or not record_data["schema_version"] is int
or int(record_data["schema_version"]) != SCHEMA_VERSION
or not record_data["local_state"] is Dictionary
or not record_data["local_checksum"] is String
or not record_data["regional_state"] is Dictionary
or not record_data["regional_checksum"] is String
):
return null
var local_state := _parse_local_state(record_data["local_state"], allow_json_number_types)
var regional_facade := _parse_regional_facade(
record_data["regional_state"], allow_json_number_types
)
if local_state == null or regional_facade == null:
return null
var local_checksum := _checksum_dictionary(local_state.to_dictionary())
var regional_checksum := _checksum_dictionary(regional_facade.to_dictionary())
if (
String(record_data["local_checksum"]) != local_checksum
or String(record_data["regional_checksum"]) != regional_checksum
):
return null
return _assemble(local_state, regional_facade)
static func from_json(json_text: String) -> SimulationSaveManifest:
var parser := JSON.new()
if parser.parse(json_text) != OK:
return null
var parsed: Variant = parser.data
if not parsed is Dictionary:
return null
var normalized: Dictionary = parsed.duplicate(true)
if not normalized.has("schema_version"):
return null
var normalized_version: Variant = _restore_json_integer_types(normalized["schema_version"])
if not normalized_version is int:
return null
normalized["schema_version"] = normalized_version
if normalized.get("local_state") is Dictionary:
var normalized_local_state: Variant = _normalize_json_local_boundary_integers(
normalized["local_state"]
)
if normalized_local_state == null:
return null
normalized["local_state"] = normalized_local_state
if normalized.get("regional_state") is Dictionary:
normalized["regional_state"] = _restore_json_integer_types(normalized["regional_state"])
return _from_dictionary(normalized, true)
func to_dictionary() -> Dictionary:
return {
"schema": SCHEMA_NAME,
"schema_version": SCHEMA_VERSION,
"local_state": _local_state.to_dictionary(),
"local_checksum": _local_checksum,
"regional_state": _regional_facade.to_dictionary(),
"regional_checksum": _regional_checksum,
}
func to_json() -> String:
return JSON.stringify(to_dictionary())
func get_local_state() -> SimulationStateRecord:
return _parse_local_state(_local_state.to_dictionary())
func get_regional_facade() -> RegionalSimulationFacade:
return _parse_regional_facade(_regional_facade.to_dictionary())
func checksum() -> String:
return to_json().sha256_text()
static func _assemble(
local_state: SimulationStateRecord, regional_facade: RegionalSimulationFacade
) -> SimulationSaveManifest:
if not _boundaries_match(local_state, regional_facade):
return null
var manifest := SimulationSaveManifest.new()
manifest._local_state = local_state
manifest._regional_facade = regional_facade
manifest._local_checksum = _checksum_dictionary(local_state.to_dictionary())
manifest._regional_checksum = _checksum_dictionary(regional_facade.to_dictionary())
return manifest
static func _parse_local_state(
value: Variant, allow_json_number_types: bool = false
) -> SimulationStateRecord:
if not value is Dictionary:
return null
var source: Dictionary = value
var parsed := SimulationStateRecord.from_dictionary(source)
if (
parsed == null
or not _canonical_tree_matches(parsed.to_dictionary(), source, allow_json_number_types)
):
return null
return parsed
static func _canonicalize_local_state(local_state: SimulationStateRecord) -> SimulationStateRecord:
var source := local_state.to_dictionary()
var source_simulation: Variant = source.get("simulation")
if not source_simulation is Dictionary:
return null
for field: String in ["seed", "tick_count"]:
if not source_simulation.get(field) is int:
return null
var parser := JSON.new()
if parser.parse(JSON.stringify(source)) != OK:
return null
if not parser.data is Dictionary:
return null
var canonical_data: Variant = _normalize_json_local_boundary_integers(parser.data)
if canonical_data == null:
return null
var canonical := SimulationStateRecord.from_dictionary(canonical_data)
if canonical == null:
return null
return _parse_local_state(canonical.to_dictionary())
static func _parse_regional_facade(
value: Variant, allow_json_number_types: bool = false
) -> RegionalSimulationFacade:
if not value is Dictionary:
return null
var source: Dictionary = value
var parsed := RegionalSimulationFacade.from_dictionary(source)
if (
parsed == null
or not _canonical_tree_matches(parsed.to_dictionary(), source, allow_json_number_types)
):
return null
return parsed
static func _boundaries_match(
local_state: SimulationStateRecord, regional_facade: RegionalSimulationFacade
) -> bool:
var local_world_id := StringName(local_state.simulation.get("world_id", &""))
var local_location_id := StringName(local_state.simulation.get("location_id", &""))
var local_seed_value: Variant = local_state.simulation.get("seed")
var local_tick_value: Variant = local_state.simulation.get("tick_count")
if not local_seed_value is int or not local_tick_value is int:
return false
var local_seed := int(local_seed_value)
var local_tick := int(local_tick_value)
if local_world_id.is_empty() or local_location_id.is_empty() or local_tick < 0:
return false
if (
local_seed != regional_facade.get_world_seed()
or local_location_id != regional_facade.get_active_location_id()
or local_tick != regional_facade.get_last_processed_tick()
):
return false
var service: RegionalCaravanService = regional_facade.get_service()
if service == null:
return false
var world_state := service.get_world_state()
if world_state == null or world_state.get_world_id() != local_world_id:
return false
var location := world_state.get_location(local_location_id)
return location != null and location.get_world_id() == local_world_id
static func _checksum_dictionary(value: Dictionary) -> String:
return JSON.stringify(_canonical_checksum_value(value)).sha256_text()
static func _canonical_checksum_value(value: Variant) -> Variant:
if _is_number(value):
var number := float(value)
if is_finite(number) and number == floorf(number) and absf(number) <= MAX_SAFE_JSON_INTEGER:
return int(number)
return number
match typeof(value):
TYPE_ARRAY:
var normalized_array: Array = []
for item: Variant in value:
normalized_array.append(_canonical_checksum_value(item))
return normalized_array
TYPE_DICTIONARY:
var normalized_dictionary: Dictionary = {}
for key: Variant in value:
normalized_dictionary[key] = _canonical_checksum_value(value[key])
return normalized_dictionary
return value
static func _has_exact_fields(value: Dictionary, fields: Array) -> bool:
return value.size() == fields.size() and value.has_all(fields)
static func _canonical_tree_matches(
canonical: Variant, source: Variant, allow_json_number_types: bool
) -> bool:
if allow_json_number_types and _is_number(canonical) and _is_number(source):
return float(canonical) == float(source)
if typeof(canonical) != typeof(source):
return false
match typeof(canonical):
TYPE_ARRAY:
if canonical.size() != source.size():
return false
for index in canonical.size():
if not _canonical_tree_matches(
canonical[index], source[index], allow_json_number_types
):
return false
return true
TYPE_DICTIONARY:
if canonical.size() != source.size():
return false
for key: Variant in canonical:
if (
not source.has(key)
or not _canonical_tree_matches(
canonical[key], source[key], allow_json_number_types
)
):
return false
return true
return canonical == source
static func _is_number(value: Variant) -> bool:
return value is int or value is float
static func _normalize_json_local_boundary_integers(value: Dictionary) -> Variant:
var normalized := value.duplicate(true)
var simulation: Variant = normalized.get("simulation")
if not simulation is Dictionary:
return normalized
for field: String in ["seed", "tick_count"]:
if not simulation.has(field):
continue
var normalized_value: Variant = _restore_json_integer_types(simulation[field])
if not normalized_value is int:
return null
simulation[field] = normalized_value
normalized["simulation"] = simulation
return normalized
static func _restore_json_integer_types(value: Variant) -> Variant:
match typeof(value):
TYPE_FLOAT:
var number := float(value)
if (
is_finite(number)
and number == floorf(number)
and absf(number) <= MAX_SAFE_JSON_INTEGER
):
return int(number)
return number
TYPE_ARRAY:
var normalized_array: Array = []
for item: Variant in value:
normalized_array.append(_restore_json_integer_types(item))
return normalized_array
TYPE_DICTIONARY:
var normalized_dictionary: Dictionary = {}
for key: Variant in value:
var normalized_value: Variant = _restore_json_integer_types(value[key])
if String(key).ends_with("_ledger") and normalized_value is Dictionary:
for ledger_key: Variant in normalized_value:
if not _is_number(normalized_value[ledger_key]):
return null
normalized_value[ledger_key] = float(normalized_value[ledger_key])
normalized_dictionary[key] = normalized_value
return normalized_dictionary
return value
@@ -0,0 +1 @@
uid://cd8g03rq1kh16
@@ -0,0 +1,370 @@
class_name RegionalSimulationFacade
extends RefCounted
const SCHEMA_VERSION := 1
const JOBS_PER_TICK := 8
const DEFAULT_WORLD_SEED := 1337
const WORLD_ID := SimulationIds.REGIONAL_WORLD_BOSNIA
const LOCATION_JAJCE := SimulationIds.REGIONAL_LOCATION_JAJCE
const LOCATION_TRAVNIK := SimulationIds.REGIONAL_LOCATION_TRAVNIK
const SETTLEMENT_JAJCE := SimulationIds.REGIONAL_SETTLEMENT_JAJCE
const SETTLEMENT_TRAVNIK := SimulationIds.REGIONAL_SETTLEMENT_TRAVNIK
const ROUTE_JAJCE_TRAVNIK := SimulationIds.REGIONAL_ROUTE_JAJCE_TRAVNIK
const CARAVAN_BLUE := SimulationIds.REGIONAL_CARAVAN_BLUE
const PERSON_ANA := SimulationIds.REGIONAL_PERSON_ANA
const COHORT_CARRIERS := SimulationIds.REGIONAL_COHORT_CARRIERS
const POLITY_JAJCE := SimulationIds.REGIONAL_POLITY_JAJCE
const POLITY_TRAVNIK := SimulationIds.REGIONAL_POLITY_TRAVNIK
const RELATION_JAJCE_TRAVNIK := &"relation_jajce_travnik"
const TRADE_GOODS := SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS
const ROUTE_TRAVEL_TICKS := 12
const JAJCE_INITIAL_TRADE_GOODS := 0.0
const TRAVNIK_INITIAL_TRADE_GOODS := 4.0
const CARAVAN_CAPACITY := 10.0
var _world_seed := DEFAULT_WORLD_SEED
var _last_processed_tick := 0
var _active_location_id := LOCATION_JAJCE
var _service: RegionalCaravanService
var _last_error := ""
static func create_jajce_route(
world_seed: int = DEFAULT_WORLD_SEED, last_processed_tick: int = 0
) -> RegionalSimulationFacade:
if last_processed_tick < 0:
return null
var service := _create_jajce_service(last_processed_tick)
if service == null:
return null
var facade := RegionalSimulationFacade.new()
facade._world_seed = world_seed
facade._last_processed_tick = last_processed_tick
facade._active_location_id = LOCATION_JAJCE
facade._service = service
return facade
static func from_dictionary(envelope: Dictionary) -> RegionalSimulationFacade:
if not _has_exact_envelope_fields(envelope):
return null
if (
not envelope["schema_version"] is int
or not envelope["world_seed"] is int
or not envelope["last_processed_tick"] is int
or not envelope["active_location_id"] is String
or not envelope["caravan_service"] is Dictionary
or int(envelope["schema_version"]) != SCHEMA_VERSION
or int(envelope["last_processed_tick"]) < 0
):
return null
var service := RegionalCaravanService.from_dictionary(envelope["caravan_service"])
var last_processed_tick := int(envelope["last_processed_tick"])
var active_location_id := StringName(envelope["active_location_id"])
if (
service == null
or not _has_required_jajce_route(service)
or active_location_id != LOCATION_JAJCE
or service.get_world_state().get_location(active_location_id) == null
or int(service.get_scheduler().get_cursor()["last_drain_tick"]) != last_processed_tick
):
return null
var facade := RegionalSimulationFacade.new()
facade._world_seed = int(envelope["world_seed"])
facade._last_processed_tick = last_processed_tick
facade._active_location_id = active_location_id
facade._service = service
return facade
func to_dictionary() -> Dictionary:
return {
"schema_version": SCHEMA_VERSION,
"world_seed": _world_seed,
"last_processed_tick": _last_processed_tick,
"active_location_id": String(_active_location_id),
"caravan_service": _service.to_dictionary(),
}
func checksum() -> String:
return JSON.stringify(to_dictionary()).sha256_text()
func get_last_processed_tick() -> int:
return _last_processed_tick
func get_world_seed() -> int:
return _world_seed
func get_active_location_id() -> StringName:
return _active_location_id
func get_service() -> RegionalCaravanService:
return _service
func get_last_error() -> String:
return _last_error
func depart_caravan(
group_id: StringName,
origin_settlement_id: StringName,
destination_settlement_id: StringName,
route_id: StringName,
cargo_to_load: Dictionary,
presentation_mode: StringName = &"never"
) -> bool:
_last_error = ""
if _service == null or not _service.is_valid():
return _fail("regional caravan service is unavailable or invalid")
var normalized_cargo: Variant = RegionalStateSupport.normalize_amount_ledger(cargo_to_load)
if (
normalized_cargo == null
or normalized_cargo.size() != 1
or not normalized_cargo.has(String(TRADE_GOODS))
):
return _fail("regional facade cargo must contain only the trade-goods ledger item")
if not _service.depart(
group_id,
origin_settlement_id,
destination_settlement_id,
route_id,
normalized_cargo,
_last_processed_tick,
presentation_mode
):
return _fail(_service.get_last_error())
return true
func depart_jajce_delivery(
amount: float = TRAVNIK_INITIAL_TRADE_GOODS, presentation_mode: StringName = &"never"
) -> bool:
return depart_caravan(
CARAVAN_BLUE,
SETTLEMENT_TRAVNIK,
SETTLEMENT_JAJCE,
ROUTE_JAJCE_TRAVNIK,
{TRADE_GOODS: amount},
presentation_mode
)
func advance_to_tick(target_tick: int, presentation_mode: StringName = &"never") -> bool:
_last_error = ""
if _service == null or not _service.is_valid():
return _fail("regional caravan service is unavailable or invalid")
if target_tick < _last_processed_tick:
return _fail(
"regional tick cannot move backward from %d to %d" % [_last_processed_tick, target_tick]
)
if target_tick == _last_processed_tick:
return true
var snapshot := to_dictionary()
var next_tick := _last_processed_tick + 1
while next_tick <= target_tick:
var unsupported_job_type := _first_unsupported_due_job_type(next_tick)
if not unsupported_job_type.is_empty():
return _rollback(
snapshot,
"scheduled regional job type '%s' has no registered handler" % unsupported_job_type
)
var due_count := _service.get_scheduler().get_due_count(next_tick)
var expected_count := mini(due_count, JOBS_PER_TICK)
var executed := _service.process_due(next_tick, JOBS_PER_TICK, presentation_mode)
if not _service.get_last_error().is_empty() or executed.size() != expected_count:
var service_error := _service.get_last_error()
if service_error.is_empty():
service_error = (
"regional scheduler executed %d of %d expected jobs"
% [executed.size(), expected_count]
)
return _rollback(snapshot, service_error)
_last_processed_tick = next_tick
next_tick += 1
return true
func _first_unsupported_due_job_type(current_tick: int) -> StringName:
var registered_job_types := _service.get_registered_job_types()
for job: ScheduledJobRecord in _service.get_scheduler().get_all_sorted():
if job.get_due_tick() > current_tick:
break
if job.get_job_type() not in registered_job_types:
return job.get_job_type()
return &""
func _rollback(snapshot: Dictionary, error_message: String) -> bool:
var restored_service := RegionalCaravanService.from_dictionary(snapshot["caravan_service"])
if restored_service == null:
return _fail("%s; facade rollback failed" % error_message)
_service = restored_service
_last_processed_tick = int(snapshot["last_processed_tick"])
return _fail(error_message)
func _fail(message: String) -> bool:
_last_error = message if not message.is_empty() else "regional facade operation failed"
return false
static func _has_exact_envelope_fields(envelope: Dictionary) -> bool:
return (
envelope.size() == 5
and (
envelope
. has_all(
[
"schema_version",
"world_seed",
"last_processed_tick",
"active_location_id",
"caravan_service",
]
)
)
)
static func _has_required_jajce_route(service: RegionalCaravanService) -> bool:
var state := service.get_world_state()
if state == null or state.get_world_id() != WORLD_ID:
return false
var route := state.get_route(ROUTE_JAJCE_TRAVNIK)
var caravan := state.get_mobile_group(CARAVAN_BLUE)
return (
state.get_location(LOCATION_JAJCE) != null
and state.get_location(LOCATION_TRAVNIK) != null
and state.get_settlement(SETTLEMENT_JAJCE) != null
and state.get_settlement(SETTLEMENT_TRAVNIK) != null
and state.get_person(PERSON_ANA) != null
and state.get_population_cohort(COHORT_CARRIERS) != null
and state.get_polity(POLITY_JAJCE) != null
and state.get_polity(POLITY_TRAVNIK) != null
and state.get_diplomatic_relation(RELATION_JAJCE_TRAVNIK) != null
and route != null
and route.connects(LOCATION_JAJCE, LOCATION_TRAVNIK)
and route.get_travel_ticks() == ROUTE_TRAVEL_TICKS
and caravan != null
and caravan.get_group_type() == &"caravan"
and caravan.get_cargo_capacity() == CARAVAN_CAPACITY
and _regional_trade_goods_are_valid(state)
)
static func _regional_trade_goods_are_valid(state: RegionalWorldState) -> bool:
var total := 0.0
for settlement: SettlementStateRecord in state.get_all_settlements():
var settlement_ledger := settlement.get_stockpile_ledger()
for item_id: Variant in settlement_ledger:
if StringName(item_id) != TRADE_GOODS:
return false
total += settlement.get_stockpile_amount(TRADE_GOODS)
for group: MobileGroupStateRecord in state.get_all_mobile_groups():
var group_ledger := group.get_cargo_ledger()
for item_id: Variant in group_ledger:
if StringName(item_id) != TRADE_GOODS:
return false
total += group.get_cargo_amount(TRADE_GOODS)
return is_equal_approx(total, TRAVNIK_INITIAL_TRADE_GOODS)
static func _create_jajce_service(last_processed_tick: int) -> RegionalCaravanService:
var jajce_location := LocationStateRecord.create(
LOCATION_JAJCE,
WORLD_ID,
&"settlement",
"Jajce",
SpatialAddress.create(WORLD_ID, LOCATION_JAJCE, Vector3.ZERO)
)
var travnik_location := LocationStateRecord.create(
LOCATION_TRAVNIK,
WORLD_ID,
&"settlement",
"Travnik",
SpatialAddress.create(WORLD_ID, LOCATION_TRAVNIK, Vector3.ZERO)
)
var jajce_settlement := SettlementStateRecord.create(
SETTLEMENT_JAJCE, LOCATION_JAJCE, "Jajce", POLITY_JAJCE, 2, &"founders_jajce", 0, {}
)
var travnik_settlement := SettlementStateRecord.create(
SETTLEMENT_TRAVNIK,
LOCATION_TRAVNIK,
"Travnik",
POLITY_TRAVNIK,
1,
&"founders_travnik",
0,
{TRADE_GOODS: TRAVNIK_INITIAL_TRADE_GOODS}
)
var route := RouteStateRecord.create(
ROUTE_JAJCE_TRAVNIK, LOCATION_JAJCE, LOCATION_TRAVNIK, ROUTE_TRAVEL_TICKS, true
)
var caravan := MobileGroupStateRecord.create_at_location(
CARAVAN_BLUE,
&"caravan",
"Blue caravan",
POLITY_TRAVNIK,
SpatialAddress.create(WORLD_ID, LOCATION_TRAVNIK, Vector3.ZERO),
[PERSON_ANA],
{COHORT_CARRIERS: 3},
{},
CARAVAN_CAPACITY
)
var ana := PersonStateRecord.create_in_group(
PERSON_ANA, "Ana", &"merchant", POLITY_TRAVNIK, LOCATION_TRAVNIK, CARAVAN_BLUE
)
var carriers := PopulationCohortRecord.create_in_group(
COHORT_CARRIERS, &"adult_carriers", POLITY_TRAVNIK, 3, CARAVAN_BLUE
)
var jajce_polity := PolityStateRecord.create(
POLITY_JAJCE, "Jajce council", LOCATION_JAJCE, [SETTLEMENT_JAJCE]
)
var travnik_polity := PolityStateRecord.create(
POLITY_TRAVNIK, "Travnik council", LOCATION_TRAVNIK, [SETTLEMENT_TRAVNIK]
)
var relation := DiplomaticRelationRecord.create(
RELATION_JAJCE_TRAVNIK, POLITY_JAJCE, POLITY_TRAVNIK, &"trade", 0.4, 0
)
if (
jajce_location == null
or travnik_location == null
or jajce_settlement == null
or travnik_settlement == null
or route == null
or caravan == null
or ana == null
or carriers == null
or jajce_polity == null
or travnik_polity == null
or relation == null
):
return null
var locations: Array[LocationStateRecord] = [jajce_location, travnik_location]
var settlements: Array[SettlementStateRecord] = [jajce_settlement, travnik_settlement]
var routes: Array[RouteStateRecord] = [route]
var groups: Array[MobileGroupStateRecord] = [caravan]
var persons: Array[PersonStateRecord] = [ana]
var cohorts: Array[PopulationCohortRecord] = [carriers]
var polities: Array[PolityStateRecord] = [jajce_polity, travnik_polity]
var relations: Array[DiplomaticRelationRecord] = [relation]
var world_state := RegionalWorldState.create(
WORLD_ID, locations, settlements, routes, groups, persons, cohorts, polities, relations
)
if world_state == null:
return null
var service := RegionalCaravanService.create(
world_state, RegionalJobScheduler.new(), WorldEventStore.new()
)
if service == null:
return null
service.process_due(last_processed_tick, JOBS_PER_TICK, &"never")
return service if service.get_last_error().is_empty() else null
@@ -0,0 +1 @@
uid://brlai5b70t6e1
+15 -4
View File
@@ -47,8 +47,11 @@ func _test_core_pack() -> void:
"Core professions should enumerate by stable ID"
)
_check(
_item_ids(catalog.get_items()) == [&"food", &"herb", &"item_claw", &"item_sword", &"wood"],
"Core items should include legacy items and herbs in stable-ID order"
(
_item_ids(catalog.get_items())
== [&"food", &"herb", &"item_claw", &"item_sword", &"regional_trade_goods", &"wood"]
),
"Core items should include local and regional resources in stable-ID order"
)
_check(
_enemy_ids(catalog.get_enemies()) == [&"enemy_boar", &"enemy_raider", &"enemy_wolf"],
@@ -102,14 +105,22 @@ func _test_deterministic_enumeration_and_dependencies() -> void:
_check(
(
_item_ids(catalog.get_items())
== [&"amber", &"food", &"herb", &"item_claw", &"item_sword", &"wood"]
== [
&"amber",
&"food",
&"herb",
&"item_claw",
&"item_sword",
&"regional_trade_goods",
&"wood",
]
),
"Merged item enumeration should be globally stable-ID sorted"
)
var returned_items := catalog.get_items()
returned_items.clear()
_check(
catalog.get_items().size() == 6,
catalog.get_items().size() == 7,
"Callers should not be able to mutate the catalog's ordered arrays"
)
@@ -0,0 +1,135 @@
extends SceneTree
const DELIVERY_AMOUNT := 3.0
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var uninterrupted := _create_manager(4242)
var local_control := _create_manager(4242)
_check(
uninterrupted.request_regional_jajce_delivery(DELIVERY_AMOUNT, &"loaded"),
"The production facade should dispatch the authored Travnik-to-Jajce delivery",
)
for step in 6:
uninterrupted.simulate_tick()
local_control.simulate_tick()
var saved_manifest_json: String = uninterrupted.serialize_save_manifest()
var saved_manifest := SimulationSaveManifest.from_json(saved_manifest_json)
_check(saved_manifest != null, "A mid-route combined manifest should validate")
var restored := _create_manager(9999)
_check(
restored.restore_save_manifest(saved_manifest),
"The local and regional authorities should restore from one manifest",
)
_check(
restored.get_world_state_checksum() == uninterrupted.get_world_state_checksum(),
"A mid-route restore should reproduce the complete authoritative checksum",
)
for step in RegionalSimulationFacade.ROUTE_TRAVEL_TICKS - 6:
uninterrupted.simulate_tick()
restored.simulate_tick()
local_control.simulate_tick()
_check(
restored.get_world_state_checksum() == uninterrupted.get_world_state_checksum(),
"Restored regional travel should continue identically without a loaded caravan visual",
)
_assert_delivery_arrived(restored)
_check(
restored.get_state_checksum() == local_control.get_state_checksum(),
"A regional delivery must not change the local simulation authority",
)
_check(
_local_economy_snapshot(restored) == _local_economy_snapshot(local_control),
"Regional trade goods must not mutate the local pantry, woodpile, or apothecary",
)
_assert_regional_events_stay_out_of_local_history(restored)
uninterrupted.free()
restored.free()
local_control.free()
if failures.is_empty():
print("[TEST] Production regional facade integration passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
root.add_child(manager)
manager.set_process(false)
return manager
func _local_economy_snapshot(manager: Node) -> Dictionary:
var apothecary: StorageStateRecord = manager.economy.get_storage(&"village_apothecary")
return {
"pantry": manager.get_pantry().to_dictionary(),
"woodpile": manager.get_woodpile().to_dictionary(),
"apothecary": apothecary.to_dictionary() if apothecary != null else {},
}
func _assert_delivery_arrived(manager: Node) -> void:
var facade := RegionalSimulationFacade.from_dictionary(manager.get_regional_snapshot())
_check(facade != null, "The production regional snapshot should remain valid")
if facade == null:
return
var service := facade.get_service()
var state := service.get_world_state()
var caravan := state.get_mobile_group(SimulationIds.REGIONAL_CARAVAN_BLUE)
_check(caravan != null and caravan.is_at_location(), "The caravan should finish at a location")
if caravan != null:
_check(
caravan.get_current_location_id() == SimulationIds.REGIONAL_LOCATION_JAJCE,
"The inbound caravan should finish in Jajce",
)
_check(
caravan.get_cargo_amount(SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS) == 0.0,
"Arrival should empty the caravan's exact cargo ledger",
)
var jajce := state.get_settlement(SimulationIds.REGIONAL_SETTLEMENT_JAJCE)
_check(
(
jajce != null
and (
jajce.get_stockpile_amount(SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS)
== DELIVERY_AMOUNT
)
),
"Jajce's regional ledger should receive the exact delivered amount",
)
_check(
service.get_event_store().size() == 4, "The regional causal chain should have four facts"
)
func _assert_regional_events_stay_out_of_local_history(manager: Node) -> void:
var regional_types := [
RegionalCaravanService.EVENT_CARGO_LOADED,
RegionalCaravanService.EVENT_DEPARTED,
RegionalCaravanService.EVENT_ARRIVED,
RegionalCaravanService.EVENT_CARGO_DEPOSITED,
]
for event: EconomicEventRecord in manager.economic_events:
_check(
StringName(event.data.get("event_type", "")) not in regional_types,
"Regional caravan facts must not enter the local numeric event-ID domain",
)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
@@ -0,0 +1 @@
uid://bl8hqwx8a8458
+198 -7
View File
@@ -17,7 +17,13 @@ func _run() -> void:
for step in 8:
manager.simulate_tick()
_check(
manager.request_regional_jajce_delivery(3.0),
"The quicksave fixture should start one production regional delivery",
)
var saved_checksum: String = manager.get_state_checksum()
var saved_regional_checksum: String = manager.get_regional_state_checksum()
var saved_world_checksum: String = manager.get_world_state_checksum()
_check(store.save(manager), "A valid simulation should save atomically")
_check(store.has_slot(), "The validated quicksave should be discoverable")
@@ -27,11 +33,28 @@ func _run() -> void:
manager.get_state_checksum() != saved_checksum,
"The scenario should change after the save point"
)
var restore_notifications: Array[StringName] = []
manager.village_changed.connect(
func(_village: SimVillage) -> void: restore_notifications.append(&"village")
)
manager.state_restored.connect(func() -> void: restore_notifications.append(&"state"))
_check(store.load_into(manager), "A valid quicksave should load")
_check(
restore_notifications == [&"village", &"state"],
"A committed combined restore should publish its two notifications exactly once",
)
_check(
manager.get_state_checksum() == saved_checksum,
"Loading should restore the exact saved simulation"
)
_check(
manager.get_regional_state_checksum() == saved_regional_checksum,
"Loading should restore the exact mid-route regional authority",
)
_check(
manager.get_world_state_checksum() == saved_world_checksum,
"Loading should restore both authorities from the same manifest",
)
var final_path := store.get_slot_path()
var backup_path := final_path + ".bak"
@@ -43,19 +66,187 @@ func _run() -> void:
store.load_into(manager),
"A preserved previous save should recover an interrupted replacement"
)
_check(
manager.get_world_state_checksum() == saved_world_checksum,
"Backup recovery should restore the complete combined manifest",
)
_check(
DirAccess.rename_absolute(backup_path, final_path) == OK,
"Test should restore the primary save path"
)
var unchanged_checksum: String = manager.get_state_checksum()
var file := FileAccess.open(final_path, FileAccess.WRITE)
file.store_string('{"schema":"broken"}')
file.close()
_check(not store.load_into(manager), "Invalid save data should be rejected")
var legacy_slot := "legacy_v16"
var legacy_path := store.get_slot_path(legacy_slot)
var legacy_local_checksum: String = manager.get_state_checksum()
var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE)
_check(legacy_file != null, "Test should create a raw legacy local save")
if legacy_file != null:
legacy_file.store_string(manager.serialize_state())
legacy_file.close()
manager.simulate_tick()
_check(
manager.get_state_checksum() == unchanged_checksum,
"A rejected save must not partially mutate the simulation"
store.load_into(manager, legacy_slot),
"Existing raw v16 quicksaves should remain loadable",
)
_check(
manager.get_state_checksum() == legacy_local_checksum,
"A legacy quicksave should restore its exact local authority",
)
var legacy_regional := RegionalSimulationFacade.from_dictionary(manager.get_regional_snapshot())
_check(legacy_regional != null, "Legacy load should bootstrap a valid regional facade")
if legacy_regional != null:
var legacy_service := legacy_regional.get_service()
var legacy_caravan := legacy_service.get_world_state().get_mobile_group(
SimulationIds.REGIONAL_CARAVAN_BLUE
)
_check(
legacy_regional.get_last_processed_tick() == manager.tick_count,
"Legacy regional bootstrap should align with the saved local tick",
)
_check(
legacy_regional.get_world_seed() == manager.simulation_seed,
"Legacy regional bootstrap should align with the saved world seed",
)
_check(
(
legacy_service.get_scheduler().is_empty()
and legacy_service.get_event_store().is_empty()
),
"Legacy load must not invent a historical regional journey",
)
_check(
(
legacy_caravan != null
and legacy_caravan.is_at_location()
and (
legacy_caravan.get_current_location_id()
== SimulationIds.REGIONAL_LOCATION_TRAVNIK
)
),
"Legacy load should place the idle authored caravan at Travnik",
)
store.delete_slot(legacy_slot)
var custom_adapter := ActiveWorldAdapter.new()
_check(
custom_adapter.configure_context_identity(
&"save_test_context", &"save_test_world", &"save_test_location"
),
"The custom-scope fixture should configure an independent active context",
)
root.add_child(custom_adapter)
var custom_manager: Node = load("res://simulation/SimulationManager.gd").new()
custom_manager.debug_logs = false
custom_manager.active_world_adapter = custom_adapter
root.add_child(custom_manager)
custom_manager.set_process(false)
var custom_slot := "custom_scope"
var custom_saved_local_checksum: String = custom_manager.get_state_checksum()
var custom_saved_regional_checksum: String = custom_manager.get_regional_state_checksum()
_check(
not custom_manager.uses_combined_save_manifest(),
"A non-Jajce adapter scope should keep the supported local-v16 save boundary",
)
_check(
custom_manager.can_serialize_local_state_only(),
"A custom scope should allow local-only saving while regional state is reconstructibly idle",
)
_check(
(
not custom_manager.request_regional_jajce_delivery(1.0)
and custom_manager.get_regional_state_checksum() == custom_saved_regional_checksum
),
"A non-Jajce adapter scope must reject Jajce regional mutation without changing authority",
)
_check(
store.save(custom_manager, custom_slot),
"A supported non-Jajce adapter scope should still save through SaveSlotStore",
)
var custom_file := FileAccess.open(store.get_slot_path(custom_slot), FileAccess.READ)
_check(custom_file != null, "The custom-scope slot should be readable")
if custom_file != null:
var custom_json := custom_file.get_as_text()
custom_file.close()
_check(
(
SimulationSaveManifest.from_json(custom_json) == null
and SimulationStateRecord.from_json(custom_json) != null
),
"A non-Jajce adapter scope should persist the explicit raw local record",
)
custom_manager.simulate_tick()
_check(
store.load_into(custom_manager, custom_slot),
"The custom-scope raw local record should remain loadable",
)
_check(
custom_manager.get_state_checksum() == custom_saved_local_checksum,
"Custom-scope loading should restore the exact local authority",
)
_check(
custom_manager.get_regional_state_checksum() == custom_saved_regional_checksum,
"Custom-scope loading should reconstruct the exact idle regional facade",
)
var rejected_restore_notifications: Array[StringName] = []
custom_manager.village_changed.connect(
func(_village: SimVillage) -> void: rejected_restore_notifications.append(&"village")
)
custom_manager.state_restored.connect(
func() -> void: rejected_restore_notifications.append(&"state")
)
var custom_before_rejected_local: String = custom_manager.get_state_checksum()
var custom_before_rejected_regional: String = custom_manager.get_regional_state_checksum()
_check(
not custom_manager.restore_save_manifest(manager.create_save_manifest()),
"A Jajce manifest should be rejected by a different active adapter scope",
)
_check(
rejected_restore_notifications.is_empty(),
"A rejected combined restore and its rollback must publish no success notifications",
)
_check(
(
custom_manager.get_state_checksum() == custom_before_rejected_local
and custom_manager.get_regional_state_checksum() == custom_before_rejected_regional
),
"A rejected combined restore should roll back both custom-scope authorities",
)
_check(
custom_manager._regional_simulation.depart_jajce_delivery(1.0),
"The fail-closed fixture should create otherwise unreachable live regional state",
)
var custom_live_regional_checksum: String = custom_manager.get_regional_state_checksum()
_check(
not custom_manager.can_serialize_local_state_only(),
"Live regional state must not be treated as reconstructible local-only authority",
)
_check(
not store.save(custom_manager, "custom_live"),
"SaveSlotStore must reject a local-only save that would discard live regional state",
)
_check(
custom_manager.get_regional_state_checksum() == custom_live_regional_checksum,
"Rejecting the lossy custom-scope save must not mutate regional authority",
)
store.delete_slot(custom_slot)
var unchanged_world_checksum: String = manager.get_world_state_checksum()
var rejected_manifest: Dictionary = manager.create_save_manifest().to_dictionary()
rejected_manifest["regional_state"]["active_location_id"] = String(
SimulationIds.REGIONAL_LOCATION_TRAVNIK
)
rejected_manifest["regional_checksum"] = SimulationSaveManifest._checksum_dictionary(
rejected_manifest["regional_state"]
)
var file := FileAccess.open(final_path, FileAccess.WRITE)
_check(file != null, "Test should open the primary save for corruption")
if file != null:
file.store_string(JSON.stringify(rejected_manifest))
file.close()
_check(not store.load_into(manager), "Invalid regional manifest data should be rejected")
_check(
manager.get_world_state_checksum() == unchanged_world_checksum,
"A rejected save must not partially mutate either authority",
)
_check(not store.save(manager, "../escape"), "Slot names must not escape the save directory")
@@ -0,0 +1,287 @@
extends GutTest
const CARGO_AMOUNT := 3.0
func test_jajce_route_bootstrap_has_stable_required_ids_and_independent_stock() -> void:
var facade := RegionalSimulationFacade.create_jajce_route(4242, 7)
assert_not_null(facade)
assert_eq(facade.get_world_seed(), 4242)
assert_eq(facade.get_last_processed_tick(), 7)
assert_eq(facade.get_active_location_id(), SimulationIds.REGIONAL_LOCATION_JAJCE)
assert_eq(
facade.to_dictionary().keys(),
[
"schema_version",
"world_seed",
"last_processed_tick",
"active_location_id",
"caravan_service",
]
)
var service := facade.get_service()
var state := service.get_world_state()
assert_eq(state.get_world_id(), SimulationIds.REGIONAL_WORLD_BOSNIA)
assert_not_null(state.get_location(RegionalSimulationFacade.LOCATION_JAJCE))
assert_not_null(state.get_location(RegionalSimulationFacade.LOCATION_TRAVNIK))
assert_not_null(state.get_settlement(RegionalSimulationFacade.SETTLEMENT_JAJCE))
assert_not_null(state.get_settlement(RegionalSimulationFacade.SETTLEMENT_TRAVNIK))
assert_not_null(state.get_route(RegionalSimulationFacade.ROUTE_JAJCE_TRAVNIK))
assert_not_null(state.get_person(RegionalSimulationFacade.PERSON_ANA))
assert_not_null(state.get_population_cohort(RegionalSimulationFacade.COHORT_CARRIERS))
assert_not_null(state.get_polity(RegionalSimulationFacade.POLITY_JAJCE))
assert_not_null(state.get_polity(RegionalSimulationFacade.POLITY_TRAVNIK))
assert_not_null(state.get_diplomatic_relation(RegionalSimulationFacade.RELATION_JAJCE_TRAVNIK))
var route := state.get_route(RegionalSimulationFacade.ROUTE_JAJCE_TRAVNIK)
assert_true(
route.connects(
RegionalSimulationFacade.LOCATION_JAJCE, RegionalSimulationFacade.LOCATION_TRAVNIK
)
)
assert_eq(route.get_travel_ticks(), RegionalSimulationFacade.ROUTE_TRAVEL_TICKS)
var caravan := state.get_mobile_group(RegionalSimulationFacade.CARAVAN_BLUE)
assert_true(caravan.is_at_location())
assert_eq(caravan.get_current_location_id(), RegionalSimulationFacade.LOCATION_TRAVNIK)
assert_eq(caravan.get_headcount(), 4)
assert_eq(caravan.get_cargo_ledger(), {})
assert_eq(
state.get_settlement(RegionalSimulationFacade.SETTLEMENT_JAJCE).get_stockpile_ledger(), {}
)
assert_eq(
state.get_settlement(RegionalSimulationFacade.SETTLEMENT_TRAVNIK).get_stockpile_amount(
SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS
),
RegionalSimulationFacade.TRAVNIK_INITIAL_TRADE_GOODS
)
assert_eq(
service.get_scheduler().get_cursor()["last_drain_tick"], facade.get_last_processed_tick()
)
_assert_regional_stock_is_independent(state)
func test_departure_and_arrival_use_facade_tick_and_conserve_regional_authority() -> void:
var facade := RegionalSimulationFacade.create_jajce_route(4242, 10)
var state := facade.get_service().get_world_state()
var goods_before := _regional_trade_goods(state)
var headcount_before := _regional_headcount(state)
assert_true(_depart(facade, CARGO_AMOUNT, &"loaded"))
var caravan := state.get_mobile_group(RegionalSimulationFacade.CARAVAN_BLUE)
assert_true(caravan.is_on_route())
assert_eq(caravan.get_departure_tick(), 10)
assert_eq(caravan.get_arrival_tick(), 10 + RegionalSimulationFacade.ROUTE_TRAVEL_TICKS)
assert_eq(caravan.get_cargo_amount(SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS), CARGO_AMOUNT)
assert_eq(_regional_trade_goods(state), goods_before)
assert_eq(_regional_headcount(state), headcount_before)
assert_true(facade.advance_to_tick(caravan.get_arrival_tick() - 1, &"unloaded"))
assert_true(caravan.is_on_route())
assert_true(facade.advance_to_tick(caravan.get_arrival_tick(), &"always"))
state = facade.get_service().get_world_state()
caravan = state.get_mobile_group(RegionalSimulationFacade.CARAVAN_BLUE)
assert_true(caravan.is_at_location())
assert_eq(caravan.get_current_location_id(), RegionalSimulationFacade.LOCATION_JAJCE)
assert_eq(caravan.get_cargo_amount(SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS), 0.0)
assert_eq(
state.get_settlement(RegionalSimulationFacade.SETTLEMENT_JAJCE).get_stockpile_amount(
SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS
),
RegionalSimulationFacade.JAJCE_INITIAL_TRADE_GOODS + CARGO_AMOUNT
)
assert_eq(_regional_trade_goods(state), goods_before)
assert_eq(_regional_headcount(state), headcount_before)
assert_eq(facade.get_service().get_event_store().size(), 4)
_assert_regional_stock_is_independent(state)
func test_ticks_are_monotonic_and_equal_tick_is_idempotent() -> void:
var facade := RegionalSimulationFacade.create_jajce_route()
assert_true(facade.advance_to_tick(4))
var checksum_at_four := facade.checksum()
assert_true(facade.advance_to_tick(4))
assert_eq(facade.checksum(), checksum_at_four)
assert_false(facade.advance_to_tick(3))
assert_true("cannot move backward" in facade.get_last_error())
assert_eq(facade.get_last_processed_tick(), 4)
assert_eq(facade.checksum(), checksum_at_four)
func test_advance_rolls_back_all_ticks_when_a_scheduled_handler_fails() -> void:
var facade := RegionalSimulationFacade.create_jajce_route()
assert_not_null(
facade.get_service().get_scheduler().schedule_new(
&"unsupported_job", &"unsupported_job", 2, 0, RegionalSimulationFacade.CARAVAN_BLUE
)
)
var before := facade.to_dictionary()
assert_false(facade.advance_to_tick(2))
assert_true("no registered handler" in facade.get_last_error())
assert_eq(facade.to_dictionary(), before)
assert_eq(facade.get_last_processed_tick(), 0)
func test_save_restore_mid_route_continues_exactly() -> void:
var uninterrupted := RegionalSimulationFacade.create_jajce_route(4242, 3)
assert_true(_depart(uninterrupted, 4.0))
assert_true(uninterrupted.advance_to_tick(8))
var saved := uninterrupted.to_dictionary()
var restored := RegionalSimulationFacade.from_dictionary(saved)
assert_not_null(restored)
assert_eq(restored.to_dictionary(), saved)
assert_eq(restored.checksum(), uninterrupted.checksum())
assert_eq(restored.get_last_processed_tick(), 8)
var arrival_tick := 3 + RegionalSimulationFacade.ROUTE_TRAVEL_TICKS
assert_true(uninterrupted.advance_to_tick(arrival_tick, &"always"))
assert_true(restored.advance_to_tick(arrival_tick, &"never"))
assert_eq(restored.to_dictionary(), uninterrupted.to_dictionary())
assert_eq(restored.checksum(), uninterrupted.checksum())
func test_presentation_modes_are_inert_observers() -> void:
var always := _complete_route(&"always", &"always")
var never := _complete_route(&"never", &"never")
var loaded_unloaded := _complete_route(&"loaded", &"unloaded")
assert_eq(always, never)
assert_eq(always, loaded_unloaded)
func test_departure_rejects_local_or_mixed_resource_ledgers_without_mutation() -> void:
var facade := RegionalSimulationFacade.create_jajce_route()
var before := facade.to_dictionary()
assert_false(
facade.depart_caravan(
RegionalSimulationFacade.CARAVAN_BLUE,
RegionalSimulationFacade.SETTLEMENT_JAJCE,
RegionalSimulationFacade.SETTLEMENT_TRAVNIK,
RegionalSimulationFacade.ROUTE_JAJCE_TRAVNIK,
{SimulationIds.RESOURCE_FOOD: 1.0}
)
)
assert_true("only the trade-goods" in facade.get_last_error())
assert_eq(facade.to_dictionary(), before)
assert_false(
(
facade
. depart_caravan(
RegionalSimulationFacade.CARAVAN_BLUE,
RegionalSimulationFacade.SETTLEMENT_JAJCE,
RegionalSimulationFacade.SETTLEMENT_TRAVNIK,
RegionalSimulationFacade.ROUTE_JAJCE_TRAVNIK,
{
SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS: 1.0,
SimulationIds.RESOURCE_WOOD: 1.0,
}
)
)
)
assert_eq(facade.to_dictionary(), before)
func test_restore_rejects_malformed_or_wrong_scope_envelopes() -> void:
var facade := RegionalSimulationFacade.create_jajce_route(4242, 5)
var saved := facade.to_dictionary()
assert_null(RegionalSimulationFacade.from_dictionary({}))
var missing_field := saved.duplicate(true)
missing_field.erase("caravan_service")
assert_null(RegionalSimulationFacade.from_dictionary(missing_field))
var extra_field := saved.duplicate(true)
extra_field["unexpected"] = true
assert_null(RegionalSimulationFacade.from_dictionary(extra_field))
var wrong_schema := saved.duplicate(true)
wrong_schema["schema_version"] = RegionalSimulationFacade.SCHEMA_VERSION + 1
assert_null(RegionalSimulationFacade.from_dictionary(wrong_schema))
var coerced_schema := saved.duplicate(true)
coerced_schema["schema_version"] = str(RegionalSimulationFacade.SCHEMA_VERSION)
assert_null(RegionalSimulationFacade.from_dictionary(coerced_schema))
var coerced_seed := saved.duplicate(true)
coerced_seed["world_seed"] = "4242"
assert_null(RegionalSimulationFacade.from_dictionary(coerced_seed))
var negative_tick := saved.duplicate(true)
negative_tick["last_processed_tick"] = -1
assert_null(RegionalSimulationFacade.from_dictionary(negative_tick))
var coerced_tick := saved.duplicate(true)
coerced_tick["last_processed_tick"] = "5"
assert_null(RegionalSimulationFacade.from_dictionary(coerced_tick))
var cursor_mismatch := saved.duplicate(true)
cursor_mismatch["last_processed_tick"] = 6
assert_null(RegionalSimulationFacade.from_dictionary(cursor_mismatch))
var wrong_active_location := saved.duplicate(true)
wrong_active_location["active_location_id"] = String(RegionalSimulationFacade.LOCATION_TRAVNIK)
assert_null(RegionalSimulationFacade.from_dictionary(wrong_active_location))
var malformed_service := saved.duplicate(true)
malformed_service["caravan_service"] = {}
assert_null(RegionalSimulationFacade.from_dictionary(malformed_service))
var wrong_world := saved.duplicate(true)
wrong_world["caravan_service"]["world_state"]["world_id"] = "another_world"
for location: Dictionary in wrong_world["caravan_service"]["world_state"]["locations"]:
location["world_id"] = "another_world"
location["address"]["world_id"] = "another_world"
for group: Dictionary in wrong_world["caravan_service"]["world_state"]["mobile_groups"]:
if group["location_address"] != null:
group["location_address"]["world_id"] = "another_world"
assert_null(RegionalSimulationFacade.from_dictionary(wrong_world))
var unsupported_stock := saved.duplicate(true)
for settlement: Dictionary in unsupported_stock["caravan_service"]["world_state"]["settlements"]:
if settlement["settlement_id"] == String(RegionalSimulationFacade.SETTLEMENT_TRAVNIK):
settlement["stockpile_ledger"][String(SimulationIds.RESOURCE_FOOD)] = 1.0
assert_null(RegionalSimulationFacade.from_dictionary(unsupported_stock))
var forged_total := saved.duplicate(true)
for settlement: Dictionary in forged_total["caravan_service"]["world_state"]["settlements"]:
if settlement["settlement_id"] == String(RegionalSimulationFacade.SETTLEMENT_TRAVNIK):
settlement["stockpile_ledger"][String(RegionalSimulationFacade.TRADE_GOODS)] = 5.0
assert_null(RegionalSimulationFacade.from_dictionary(forged_total))
func _depart(
facade: RegionalSimulationFacade, amount: float, presentation_mode: StringName = &"never"
) -> bool:
return facade.depart_jajce_delivery(amount, presentation_mode)
func _complete_route(
departure_presentation: StringName, arrival_presentation: StringName
) -> Dictionary:
var facade := RegionalSimulationFacade.create_jajce_route()
assert_true(_depart(facade, CARGO_AMOUNT, departure_presentation))
assert_true(
facade.advance_to_tick(RegionalSimulationFacade.ROUTE_TRAVEL_TICKS, arrival_presentation)
)
return facade.to_dictionary()
func _regional_trade_goods(state: RegionalWorldState) -> float:
var total := 0.0
for settlement: SettlementStateRecord in state.get_all_settlements():
total += settlement.get_stockpile_amount(SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS)
for group: MobileGroupStateRecord in state.get_all_mobile_groups():
total += group.get_cargo_amount(SimulationIds.RESOURCE_REGIONAL_TRADE_GOODS)
return total
func _regional_headcount(state: RegionalWorldState) -> int:
var total := 0
for group: MobileGroupStateRecord in state.get_all_mobile_groups():
total += group.get_headcount()
for cohort: PopulationCohortRecord in state.get_all_population_cohorts():
if cohort.is_at_location():
total += cohort.get_count()
for person: PersonStateRecord in state.get_all_persons():
if person.is_at_location():
total += 1
return total
func _assert_regional_stock_is_independent(state: RegionalWorldState) -> void:
for settlement: SettlementStateRecord in state.get_all_settlements():
assert_false(settlement.get_stockpile_ledger().has(String(SimulationIds.RESOURCE_FOOD)))
assert_false(settlement.get_stockpile_ledger().has(String(SimulationIds.RESOURCE_WOOD)))
for group: MobileGroupStateRecord in state.get_all_mobile_groups():
assert_false(group.get_cargo_ledger().has(String(SimulationIds.RESOURCE_FOOD)))
assert_false(group.get_cargo_ledger().has(String(SimulationIds.RESOURCE_WOOD)))
@@ -0,0 +1 @@
uid://0flo0tck1bwn
+280
View File
@@ -0,0 +1,280 @@
extends GutTest
const MANAGER_SCRIPT := preload("res://simulation/SimulationManager.gd")
var manager: Node
func before_each() -> void:
manager = MANAGER_SCRIPT.new()
add_child_autofree(manager)
func test_canonical_manifest_round_trips_defensively() -> void:
var local_state: SimulationStateRecord = manager.create_state_record()
var regional_facade := RegionalSimulationFacade.create_jajce_route()
var manifest := SimulationSaveManifest.create(local_state, regional_facade)
assert_not_null(manifest)
var saved := manifest.to_dictionary()
var expected_fields := [
"local_checksum",
"local_state",
"regional_checksum",
"regional_state",
"schema",
"schema_version",
]
var saved_fields := saved.keys()
saved_fields.sort()
assert_eq(saved_fields, expected_fields)
assert_eq(saved["schema"], SimulationSaveManifest.SCHEMA_NAME)
assert_eq(saved["schema_version"], SimulationSaveManifest.SCHEMA_VERSION)
assert_eq(
saved["local_checksum"],
SimulationSaveManifest._checksum_dictionary(saved["local_state"]),
)
assert_eq(
saved["regional_checksum"],
SimulationSaveManifest._checksum_dictionary(saved["regional_state"]),
)
var restored := SimulationSaveManifest.from_dictionary(saved)
var restored_json := SimulationSaveManifest.from_json(manifest.to_json())
assert_not_null(restored)
assert_not_null(restored_json)
assert_eq(restored.to_dictionary(), saved)
assert_eq(restored.checksum(), manifest.checksum())
assert_eq(restored_json.checksum(), manifest.checksum())
assert_not_same(manifest.get_local_state(), local_state)
assert_not_same(manifest.get_regional_facade(), regional_facade)
local_state.simulation["tick_count"] = 99
var copied_local := manifest.get_local_state()
copied_local.simulation["tick_count"] = 77
assert_eq(int(manifest.get_local_state().simulation["tick_count"]), 0)
var copied_regional := manifest.get_regional_facade().to_dictionary()
copied_regional["last_processed_tick"] = 88
assert_eq(manifest.get_regional_facade().get_last_processed_tick(), 0)
func test_parser_rejects_missing_extra_and_wrong_manifest_fields() -> void:
var valid := _valid_manifest_dictionary()
for field: String in SimulationSaveManifest.MANIFEST_FIELDS:
var missing := valid.duplicate(true)
missing.erase(field)
assert_null(SimulationSaveManifest.from_dictionary(missing), "Missing %s" % field)
var extra := valid.duplicate(true)
extra["unexpected"] = true
assert_null(SimulationSaveManifest.from_dictionary(extra))
var wrong_schema := valid.duplicate(true)
wrong_schema["schema"] = &"the_steward.save_manifest"
assert_null(SimulationSaveManifest.from_dictionary(wrong_schema))
var wrong_version := valid.duplicate(true)
wrong_version["schema_version"] = "1"
assert_null(SimulationSaveManifest.from_dictionary(wrong_version))
var wrong_float_version := valid.duplicate(true)
wrong_float_version["schema_version"] = 1.0
assert_null(SimulationSaveManifest.from_dictionary(wrong_float_version))
var wrong_local := valid.duplicate(true)
wrong_local["local_state"] = []
assert_null(SimulationSaveManifest.from_dictionary(wrong_local))
var wrong_regional := valid.duplicate(true)
wrong_regional["regional_state"] = "regional"
assert_null(SimulationSaveManifest.from_dictionary(wrong_regional))
var wrong_local_checksum := valid.duplicate(true)
wrong_local_checksum["local_checksum"] = 1
assert_null(SimulationSaveManifest.from_dictionary(wrong_local_checksum))
var wrong_regional_checksum := valid.duplicate(true)
wrong_regional_checksum["regional_checksum"] = []
assert_null(SimulationSaveManifest.from_dictionary(wrong_regional_checksum))
assert_null(SimulationSaveManifest.from_json("[]"))
assert_null(SimulationSaveManifest.from_json("not json"))
func test_nested_tampering_and_checksum_mismatches_fail_closed() -> void:
var valid := _valid_manifest_dictionary()
var local_checksum_mismatch := valid.duplicate(true)
local_checksum_mismatch["local_checksum"] = "0".repeat(64)
assert_null(SimulationSaveManifest.from_dictionary(local_checksum_mismatch))
var regional_checksum_mismatch := valid.duplicate(true)
regional_checksum_mismatch["regional_checksum"] = "f".repeat(64)
assert_null(SimulationSaveManifest.from_dictionary(regional_checksum_mismatch))
var tampered_local := valid.duplicate(true)
tampered_local["local_state"]["simulation"]["seed"] = 4004
assert_null(SimulationSaveManifest.from_dictionary(tampered_local))
var tampered_regional := valid.duplicate(true)
tampered_regional["regional_state"]["last_processed_tick"] = 1
assert_null(SimulationSaveManifest.from_dictionary(tampered_regional))
var nested_extra := valid.duplicate(true)
nested_extra["local_state"]["unexpected"] = true
nested_extra["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
nested_extra["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(nested_extra))
assert_null(SimulationSaveManifest.from_json(JSON.stringify(nested_extra)))
func test_local_seed_and_tick_require_exact_integer_types() -> void:
var valid := _valid_manifest_dictionary()
var invalid_boundaries: Array[Dictionary] = [
{"field": "seed", "value": "1337"},
{"field": "seed", "value": 1337.5},
{"field": "tick_count", "value": "0"},
{"field": "tick_count", "value": 0.5},
]
for invalid_boundary: Dictionary in invalid_boundaries:
var field := String(invalid_boundary["field"])
var value: Variant = invalid_boundary["value"]
var malformed := valid.duplicate(true)
malformed["local_state"]["simulation"][field] = value
malformed["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
malformed["local_state"]
)
assert_null(
SimulationSaveManifest.from_dictionary(malformed),
"Dictionary parser should reject %s=%s" % [field, value],
)
assert_null(
SimulationSaveManifest.from_json(JSON.stringify(malformed)),
"JSON parser should reject %s=%s" % [field, value],
)
var local_state: SimulationStateRecord = manager.create_state_record()
local_state.simulation[field] = value
assert_null(
SimulationSaveManifest.create(
local_state, RegionalSimulationFacade.create_jajce_route()
),
"Manifest creation should reject %s=%s" % [field, value],
)
func test_json_parser_rejects_nonnumeric_regional_ledger_values() -> void:
var string_ledger := _valid_manifest_dictionary()
_set_regional_stockpile_value(
string_ledger["regional_state"],
RegionalSimulationFacade.SETTLEMENT_TRAVNIK,
"4",
)
assert_null(SimulationSaveManifest.from_json(JSON.stringify(string_ledger)))
var boolean_ledger := _valid_manifest_dictionary()
_set_regional_stockpile_value(
boolean_ledger["regional_state"],
RegionalSimulationFacade.SETTLEMENT_JAJCE,
false,
)
var canonical_regional_state: Dictionary = boolean_ledger["regional_state"].duplicate(true)
_set_regional_stockpile_value(
canonical_regional_state,
RegionalSimulationFacade.SETTLEMENT_JAJCE,
0.0,
)
boolean_ledger["regional_checksum"] = SimulationSaveManifest._checksum_dictionary(
canonical_regional_state
)
assert_null(SimulationSaveManifest.from_json(JSON.stringify(boolean_ledger)))
func test_world_location_and_tick_must_match_the_regional_boundary() -> void:
var valid := _valid_manifest_dictionary()
var wrong_world := valid.duplicate(true)
wrong_world["local_state"]["simulation"]["world_id"] = "regional_elsewhere"
wrong_world["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_world["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_world))
var wrong_location := valid.duplicate(true)
wrong_location["local_state"]["simulation"]["location_id"] = String(
RegionalSimulationFacade.LOCATION_TRAVNIK
)
wrong_location["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_location["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_location))
var wrong_seed := valid.duplicate(true)
wrong_seed["local_state"]["simulation"]["seed"] = (
RegionalSimulationFacade.DEFAULT_WORLD_SEED + 1
)
wrong_seed["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_seed["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_seed))
var wrong_regional_seed := valid.duplicate(true)
wrong_regional_seed["regional_state"]["world_seed"] = (
RegionalSimulationFacade.DEFAULT_WORLD_SEED + 1
)
wrong_regional_seed["regional_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_regional_seed["regional_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_regional_seed))
var wrong_tick := valid.duplicate(true)
wrong_tick["local_state"]["simulation"]["tick_count"] = 1
wrong_tick["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_tick["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_tick))
assert_null(
SimulationSaveManifest.create(
manager.create_state_record(),
RegionalSimulationFacade.create_jajce_route(
RegionalSimulationFacade.DEFAULT_WORLD_SEED, 1
)
)
)
assert_null(
SimulationSaveManifest.create(
manager.create_state_record(),
RegionalSimulationFacade.create_jajce_route(
RegionalSimulationFacade.DEFAULT_WORLD_SEED + 1, 0
)
)
)
var negative_seed_local: SimulationStateRecord = manager.create_state_record()
negative_seed_local.simulation["seed"] = -42
assert_not_null(
SimulationSaveManifest.create(
negative_seed_local, RegionalSimulationFacade.create_jajce_route(-42, 0)
)
)
manager.simulate_tick()
var synchronized := SimulationSaveManifest.create(
manager.create_state_record(),
RegionalSimulationFacade.create_jajce_route(RegionalSimulationFacade.DEFAULT_WORLD_SEED, 1)
)
assert_not_null(synchronized)
assert_eq(synchronized.get_regional_facade().get_last_processed_tick(), 1)
assert_eq(int(synchronized.get_local_state().simulation["tick_count"]), 1)
func _valid_manifest_dictionary() -> Dictionary:
var manifest := SimulationSaveManifest.create(
manager.create_state_record(), RegionalSimulationFacade.create_jajce_route()
)
assert_not_null(manifest)
return manifest.to_dictionary()
func _set_regional_stockpile_value(
regional_state: Dictionary, settlement_id: StringName, value: Variant
) -> void:
for settlement: Dictionary in regional_state["caravan_service"]["world_state"]["settlements"]:
if StringName(settlement["settlement_id"]) == settlement_id:
settlement["stockpile_ledger"][String(RegionalSimulationFacade.TRADE_GOODS)] = value
return
assert_true(false, "Expected regional settlement '%s'" % settlement_id)
@@ -0,0 +1 @@
uid://cradryxj45n86