feat: integrate regional caravan persistence
This commit is contained in:
+13
-12
@@ -21,10 +21,11 @@ own persistent quantities, travel completion, or quest progress.
|
|||||||
The long-term direction is one regional simulation containing settlements,
|
The long-term direction is one regional simulation containing settlements,
|
||||||
routes, caravans, named people, aggregate cohorts, and polities. The current
|
routes, caravans, named people, aggregate cohorts, and polities. The current
|
||||||
repository has the regional records, deterministic scheduler, one exact
|
repository has the regional records, deterministic scheduler, one exact
|
||||||
caravan transfer service, chunked persistence, and a 20-caravan structural
|
caravan transfer service, a production facade for one inbound Jajce route, a
|
||||||
benchmark. It does not yet run that regional service as the production
|
combined local/regional save manifest, chunked persistence, and a 20-caravan
|
||||||
`SimulationManager` facade or provide a complete market, settlement-growth, or
|
structural benchmark. It does not yet provide a complete market,
|
||||||
kingdom gameplay loop. That distinction is important when extending the game.
|
settlement-growth, or kingdom gameplay loop. That distinction is important
|
||||||
|
when extending the game.
|
||||||
|
|
||||||
## Start here
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## The authority pipeline
|
||||||
|
|
||||||
@@ -106,9 +107,10 @@ relationship, or reward that the event claims happened.
|
|||||||
- `simulation/events/`, `knowledge/`, `relationships/`, `situations/`, and
|
- `simulation/events/`, `knowledge/`, `relationships/`, `situations/`, and
|
||||||
`dialogue/` — causal history and emergent narrative layers.
|
`dialogue/` — causal history and emergent narrative layers.
|
||||||
- `simulation/state/` — versioned primitive records and validation.
|
- `simulation/state/` — versioned primitive records and validation.
|
||||||
- `simulation/persistence/` — local quicksave safety.
|
- `simulation/persistence/` — combined quicksave manifest and atomic slot
|
||||||
- `simulation/regional/` — regional records, scheduler, caravan proof, chunk
|
replacement, plus regional chunk codecs and generations.
|
||||||
codec, and file generations.
|
- `simulation/regional/` — production facade, regional records, scheduler,
|
||||||
|
caravan transaction, retention planning, and scale contracts.
|
||||||
|
|
||||||
### Loaded world and presentation
|
### Loaded world and presentation
|
||||||
|
|
||||||
@@ -161,12 +163,11 @@ are used when an existing save is loaded.
|
|||||||
|
|
||||||
The following are deliberately **not** claimed as complete:
|
The following are deliberately **not** claimed as complete:
|
||||||
|
|
||||||
- wire `RegionalCaravanService` into the production regional facade and local
|
- integrate event retention/rollups/chunked history into one atomic,
|
||||||
save manifest;
|
lazily loaded long-horizon save;
|
||||||
- run 20 real caravan trades across five settlement economies with atomic
|
- run 20 real caravan trades across five settlement economies with atomic
|
||||||
cargo/stock ledgers;
|
cargo/stock ledgers;
|
||||||
- replace local unloaded travel freezes with authoritative route-time work;
|
- 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,
|
- add settlement projects, cohort promotion/demotion, offices, diplomacy,
|
||||||
armies, campaigns, and aggregate conflict;
|
armies, campaigns, and aggregate conflict;
|
||||||
- add visual relevance streaming to the live world view rather than only the
|
- 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
|
The regional package is the scale foundation for one authority spanning
|
||||||
settlements, routes, caravans, named people, aggregate cohorts, and polities.
|
settlements, routes, caravans, named people, aggregate cohorts, and polities.
|
||||||
It is intentionally additive: the current Jajce `SimulationManager` remains
|
The production `SimulationManager` now owns a bounded regional facade for one
|
||||||
the playable local facade while the regional contracts prove deterministic
|
inbound Jajce route, while the broader regional contracts continue to prove
|
||||||
unloaded work and conservation in isolation.
|
deterministic unloaded work and conservation before wider integration.
|
||||||
|
|
||||||
## Regional record model
|
## 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,
|
provide deterministic save/restore. The test runs always-loaded, never-loaded,
|
||||||
and load/unload presentation modes through the same authoritative service.
|
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
|
## Chunked persistence
|
||||||
|
|
||||||
`RegionalChunkedPersistence` captures the service into a manifest plus fixed
|
`RegionalChunkedPersistence` captures the service into a manifest plus fixed
|
||||||
@@ -89,11 +95,20 @@ primitive-tree bounds.
|
|||||||
|
|
||||||
## Current integration boundary
|
## 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;
|
The remaining boundary is intentionally narrow:
|
||||||
- `RegionalCaravanService` is a complete isolated transfer proof, not the live
|
|
||||||
economy's 20-trade scheduler;
|
- 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
|
- event retention/rollups and regional chunk files are not yet one integrated
|
||||||
long-horizon save transaction;
|
long-horizon save transaction;
|
||||||
- loaded/unloaded local NPC travel still has the old active-visual boundary;
|
- 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.
|
aggregate conflict remain planned.
|
||||||
|
|
||||||
Do not create one `SimulationManager` per settlement or caravan. The next
|
Do not create one `SimulationManager` per settlement or caravan. The next
|
||||||
production step is an explicit regional facade that delegates to these records
|
production step is to publish retained raw events, daily rollups, and chunked
|
||||||
and services, while the active adapter presents only a bounded relevant context.
|
regional history as one atomic, lazily loaded transaction behind the facade.
|
||||||
|
|
||||||
## Scale evidence
|
## Scale evidence
|
||||||
|
|
||||||
|
|||||||
@@ -64,11 +64,22 @@ catch corruption.
|
|||||||
|
|
||||||
## Local file safety
|
## Local file safety
|
||||||
|
|
||||||
`SaveSlotStore` validates the complete JSON before replacement, restricts slot
|
`SimulationSaveManifest` v1 combines the unchanged local v16 record and the
|
||||||
names and size, preserves a previous file, and can recover a backup after an
|
canonical `RegionalSimulationFacade` envelope. It stores an independent
|
||||||
interrupted replacement. `F5`/`F9` use the current quicksave path in `main.tscn`.
|
checksum for each authority and cross-validates world, seed, active location,
|
||||||
The player transform and presentation-only scene state are intentionally not
|
and processed tick. A rejected manifest cannot partially replace local or
|
||||||
saved.
|
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
|
## 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
|
pointer, recovery scanning, and conservative pruning. Partial active-location
|
||||||
loads are metadata-only and cannot silently resurrect a removed location.
|
loads are metadata-only and cannot silently resurrect a removed location.
|
||||||
|
|
||||||
This regional codec is not yet the backend for local `SaveSlotStore`. Integrate
|
This regional chunk codec is not yet the backend for `SaveSlotStore`. The
|
||||||
it only after the production regional facade, event retention, and lazy context
|
production facade and combined quicksave manifest are complete; retained raw
|
||||||
loading have one atomic manifest contract.
|
events, rollups, chunk generations, and lazy context loading still need one
|
||||||
|
atomic long-horizon publication contract.
|
||||||
|
|
||||||
## How to change state safely
|
## How to change state safely
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ For a new system, add tests for:
|
|||||||
|
|
||||||
## Current evidence and gaps
|
## 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
|
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
|
on the reviewed development host. The regional baseline proves the target
|
||||||
record counts and scheduler parity. Neither measures a complete regional
|
record counts and scheduler parity. Neither measures a complete regional
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# The Steward — Learning and Reusable-Systems Roadmap
|
# 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
|
> chronological learning record. The current code has completed the authored
|
||||||
> content/catalog, shared target/command, event/social, situation/dialogue,
|
> content/catalog, shared target/command, event/social, situation/dialogue,
|
||||||
> entity-family, context-scoping, and first regional record/caravan/chunk
|
> 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
|
> start with [`DEVELOPER_INDEX.md`](DEVELOPER_INDEX.md). Do not infer current
|
||||||
> behavior from an older milestone paragraph when the index or code disagrees.
|
> 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
|
event-backed social commitment whose ordinary deposit outcome survives
|
||||||
save/load. Definition packs now cover reusable items, resources, storage,
|
save/load. Definition packs now cover reusable items, resources, storage,
|
||||||
enemies, and animals. Regional records, deterministic scheduled work, bounded
|
enemies, and animals. Regional records, deterministic scheduled work, bounded
|
||||||
presentation relevance, and the first authoritative caravan transfer form the
|
presentation relevance, and the first authoritative caravan transfer now feed
|
||||||
next world-slice foundation. See
|
one production facade and combined local/regional save boundary. See
|
||||||
[ADR 0001](decisions/0001-simulation-authority-boundary.md),
|
[ADR 0001](decisions/0001-simulation-authority-boundary.md),
|
||||||
[ADR 0002](decisions/0002-emergent-world-content-and-narrative.md), and the
|
[ADR 0002](decisions/0002-emergent-world-content-and-narrative.md), and the
|
||||||
[regional simulation contract](REGIONAL_SIMULATION.md).
|
[regional simulation contract](REGIONAL_SIMULATION.md).
|
||||||
@@ -1107,6 +1108,12 @@ resolution, and personal request accept/decline.
|
|||||||
|
|
||||||
Recently completed:
|
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
|
- `Jajce Villager Field Note 12`: a separate player-facing note selects the
|
||||||
nearest loaded living villager and shows live name, action, task state, exact
|
nearest loaded living villager and shows live name, action, task state, exact
|
||||||
target, carried inventory, and a currently matching decision reason. It
|
target, carried inventory, and a currently matching decision reason. It
|
||||||
|
|||||||
+37
-16
@@ -1,10 +1,11 @@
|
|||||||
# Regional simulation contract
|
# 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
|
> chunk, file-generation, relevance, and navigation-budget contracts are
|
||||||
> implemented and tested. They are still an additive regional package; the
|
> implemented and tested. `SimulationManager` now owns a production regional
|
||||||
> playable Jajce `SimulationManager` and `SaveSlotStore` remain the local
|
> facade for one inbound Jajce route, and `SaveSlotStore` persists local and
|
||||||
> production authority until the integrations listed at the end land.
|
> 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
|
The regional layer extends the Jajce simulation without multiplying scene-tree
|
||||||
managers. One authority owns every loaded and unloaded location, settlement,
|
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
|
serialization sorts record families and IDs, making checksums independent of
|
||||||
construction order.
|
construction order.
|
||||||
|
|
||||||
The current local `SimulationStateRecord` remains the v16 Jajce save boundary.
|
The current local `SimulationStateRecord` remains the unchanged v16 Jajce
|
||||||
Regional state is deliberately carried in its own versioned service envelope
|
record. `SimulationSaveManifest` v1 combines that record with the canonical
|
||||||
until one playable route consumes it; this avoids placing speculative regional
|
`RegionalSimulationFacade` envelope and checksums both authorities. Regional
|
||||||
fields in every local save. The merge into the production save manifest must
|
fields are not copied into local records or scene nodes.
|
||||||
retain this canonical regional payload rather than copying its fields into
|
|
||||||
scene nodes.
|
|
||||||
|
|
||||||
## Deterministic work
|
## 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
|
arrival tick, queue state, and checksum. Nearby visuals may interpolate route
|
||||||
progress; they do not report elapsed travel completion.
|
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
|
## Event retention and daily rollups
|
||||||
|
|
||||||
`WorldEventRetentionPlanner` is a pure, deterministic query over a complete
|
`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
|
Build in this order, keeping each step behind conservation and continuation
|
||||||
tests:
|
tests:
|
||||||
|
|
||||||
1. make the Jajce route proof callable from the production regional facade and
|
1. integrate the retention/rollup and chunk codecs as one atomic,
|
||||||
save manifest;
|
|
||||||
2. integrate the retention/rollup and chunk codecs as one atomic,
|
|
||||||
lazily loaded history transaction;
|
lazily loaded history transaction;
|
||||||
3. schedule 20 ordinary caravan trades across five settlement economies;
|
2. schedule 20 ordinary caravan trades across five settlement economies;
|
||||||
4. promote/demote presentation and population fidelity while pinning named and
|
3. promote/demote presentation and population fidelity while pinning named and
|
||||||
causally referenced people;
|
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.
|
armies, campaigns, and aggregate conflict.
|
||||||
|
|
||||||
Context-owned world-target registries and the first shared player/NPC activity
|
Context-owned world-target registries and the first shared player/NPC activity
|
||||||
|
|||||||
@@ -451,12 +451,20 @@ replayed after restore.
|
|||||||
|
|
||||||
## Local quicksave boundary
|
## Local quicksave boundary
|
||||||
|
|
||||||
`SaveSlotStore` writes the existing versioned local JSON record to
|
`SaveSlotStore` writes `SimulationSaveManifest` v1 to
|
||||||
`user://saves/quicksave.json`. It validates the serialized record before
|
`user://saves/quicksave.json`. The manifest contains the unchanged local v16
|
||||||
replacement, limits file size, restricts slot names, preserves the previous
|
record and canonical regional facade envelope, with an independent checksum
|
||||||
file during replacement, and can recover that backup if replacement is
|
for each. Restore also requires world, seed, active location, and tick to agree
|
||||||
interrupted. Loading parses and validates the complete record before mutating
|
across both authorities, and rolls both back if local restoration fails.
|
||||||
the simulation.
|
|
||||||
|
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
|
In `main.tscn`, F5 saves and F9 loads this slot. Restoration rebuilds active NPC
|
||||||
visuals from authoritative state.
|
visuals from authoritative state.
|
||||||
@@ -476,8 +484,8 @@ This phase does not yet provide:
|
|||||||
- persistence of a currently open dialogue UI/turn. Semantic conversation
|
- persistence of a currently open dialogue UI/turn. Semantic conversation
|
||||||
history and commitment authority persist, while a balloon is regenerated
|
history and commitment authority persist, while a balloon is regenerated
|
||||||
after restore;
|
after restore;
|
||||||
- production integration of the regional chunked save manifest, lazy location
|
- production integration of regional retained/rolled-up chunk history, lazy
|
||||||
loading, or aggregate population promotion/persistence;
|
location loading, or aggregate population promotion/persistence;
|
||||||
- persistence for the player transform or presentation-only scene state.
|
- persistence for the player transform or presentation-only scene state.
|
||||||
|
|
||||||
Those features should build on this boundary rather than inventing parallel
|
Those features should build on this boundary rather than inventing parallel
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd")
|
|||||||
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||||
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
||||||
const ConflictSystemScript := preload("res://simulation/conflict/ConflictSystem.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 npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
||||||
signal village_changed(village: SimVillage)
|
signal village_changed(village: SimVillage)
|
||||||
@@ -84,6 +87,8 @@ var conflict_system := ConflictSystemScript.new()
|
|||||||
var player_quest_system := PlayerQuestSystem.new()
|
var player_quest_system := PlayerQuestSystem.new()
|
||||||
var player_needs := PlayerNeedsSystem.new()
|
var player_needs := PlayerNeedsSystem.new()
|
||||||
var player_negotiation := PlayerNegotiationSystem.new()
|
var player_negotiation := PlayerNegotiationSystem.new()
|
||||||
|
var _regional_simulation: RegionalSimulationFacade
|
||||||
|
var _restoring_save_manifest := false
|
||||||
var _last_player_tier := PlayerStandingRecord.TIER_STRANGER
|
var _last_player_tier := PlayerStandingRecord.TIER_STRANGER
|
||||||
var storage_states: Dictionary:
|
var storage_states: Dictionary:
|
||||||
get:
|
get:
|
||||||
@@ -165,6 +170,9 @@ func _ready() -> void:
|
|||||||
clock = SimulationClock.new(tick_interval)
|
clock = SimulationClock.new(tick_interval)
|
||||||
clock.cycle_duration_seconds = cycle_duration_seconds
|
clock.cycle_duration_seconds = cycle_duration_seconds
|
||||||
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
|
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
|
village.debug_logs = debug_logs
|
||||||
economy.configure(village, debug_logs)
|
economy.configure(village, debug_logs)
|
||||||
animal_care.configure(economy, active_world_adapter)
|
animal_care.configure(economy, active_world_adapter)
|
||||||
@@ -248,7 +256,17 @@ func get_wander_offset(npc_id: int) -> Vector3:
|
|||||||
|
|
||||||
|
|
||||||
func simulate_tick() -> void:
|
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:
|
if debug_logs:
|
||||||
print("--- Tick ", tick_count, " ---")
|
print("--- Tick ", tick_count, " ---")
|
||||||
animal_care.advance(tick_count)
|
animal_care.advance(tick_count)
|
||||||
@@ -2408,6 +2426,34 @@ func get_state_checksum() -> String:
|
|||||||
return create_state_record().to_json().sha256_text()
|
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:
|
func get_latest_decision(npc_id: int) -> ActionSelectionResult:
|
||||||
return latest_decisions.get(npc_id)
|
return latest_decisions.get(npc_id)
|
||||||
|
|
||||||
@@ -2484,6 +2530,59 @@ func serialize_state() -> String:
|
|||||||
return create_state_record().to_json()
|
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:
|
func restore_state_from_json(json_text: String) -> bool:
|
||||||
var record := SimulationStateRecord.from_json(json_text)
|
var record := SimulationStateRecord.from_json(json_text)
|
||||||
if record == null:
|
if record == null:
|
||||||
@@ -2578,8 +2677,24 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
conflict_system.register_npc_combatants(npcs)
|
conflict_system.register_npc_combatants(npcs)
|
||||||
conflict_system.configure(economy, tick_interval)
|
conflict_system.configure(economy, tick_interval)
|
||||||
_sync_player_combatant()
|
_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)
|
village_changed.emit(village)
|
||||||
state_restored.emit()
|
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
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ const WORLD_CORE := &"world_core"
|
|||||||
const LOCATION_JAJCE := &"settlement_jajce"
|
const LOCATION_JAJCE := &"settlement_jajce"
|
||||||
const REGIONAL_WORLD_BOSNIA := &"regional_bosnia"
|
const REGIONAL_WORLD_BOSNIA := &"regional_bosnia"
|
||||||
const REGIONAL_LOCATION_JAJCE := &"location_jajce"
|
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_GUARD_POST := &"guard_post"
|
||||||
const ACTIVITY_STUDY_DESK := &"study_desk"
|
const ACTIVITY_STUDY_DESK := &"study_desk"
|
||||||
const ACTION_HANDLER_ACTIVITY_METRIC_DELTA := &"activity_metric_delta"
|
const ACTION_HANDLER_ACTIVITY_METRIC_DELTA := &"activity_metric_delta"
|
||||||
@@ -51,6 +60,7 @@ const TARGET_FREE := &"free"
|
|||||||
|
|
||||||
const RESOURCE_FOOD := &"food"
|
const RESOURCE_FOOD := &"food"
|
||||||
const RESOURCE_WOOD := &"wood"
|
const RESOURCE_WOOD := &"wood"
|
||||||
|
const RESOURCE_REGIONAL_TRADE_GOODS := &"regional_trade_goods"
|
||||||
|
|
||||||
const ANIMAL_DUNJA := &"goat_dunja"
|
const ANIMAL_DUNJA := &"goat_dunja"
|
||||||
const ANIMAL_ZORA := &"goat_zora"
|
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"
|
||||||
@@ -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="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"]
|
||||||
[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"]
|
[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_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_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/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]
|
[resource]
|
||||||
script = ExtResource("1_pack")
|
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")]
|
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")]
|
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")]
|
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")]
|
resources = [ExtResource("33_berry_patch"), ExtResource("34_herb_patch"), ExtResource("35_tree")]
|
||||||
storages = [ExtResource("36_apothecary"), ExtResource("37_pantry"), ExtResource("38_woodpile")]
|
storages = [ExtResource("36_apothecary"), ExtResource("37_pantry"), ExtResource("38_woodpile")]
|
||||||
enemies = [ExtResource("24_raider"), ExtResource("25_wolf"), ExtResource("26_boar")]
|
enemies = [ExtResource("24_raider"), ExtResource("25_wolf"), ExtResource("26_boar")]
|
||||||
|
|||||||
@@ -15,16 +15,36 @@ func _init(directory: String = DEFAULT_DIRECTORY) -> void:
|
|||||||
|
|
||||||
func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
||||||
last_error = ""
|
last_error = ""
|
||||||
if manager == null or not manager.has_method("serialize_state"):
|
if (
|
||||||
return _fail("Save source cannot serialize simulation state")
|
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):
|
if not _is_valid_slot_name(slot_name):
|
||||||
return _fail("Invalid save 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:
|
if json_text.to_utf8_buffer().size() > MAX_SAVE_BYTES:
|
||||||
return _fail("Save exceeds the supported size limit")
|
return _fail("Save exceeds the supported size limit")
|
||||||
if SimulationStateRecord.from_json(json_text) == null:
|
if not _is_valid_save_json(json_text):
|
||||||
return _fail("Simulation produced an invalid save record")
|
return _fail("Simulation produced an invalid save payload")
|
||||||
if not _ensure_directory():
|
if not _ensure_directory():
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -39,7 +59,7 @@ func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
|||||||
file.store_string(json_text)
|
file.store_string(json_text)
|
||||||
file.flush()
|
file.flush()
|
||||||
file.close()
|
file.close()
|
||||||
if _read_record(temporary_path) == null:
|
if _read_payload(temporary_path).is_empty():
|
||||||
_remove_if_present(temporary_path)
|
_remove_if_present(temporary_path)
|
||||||
return _fail("Temporary save failed validation")
|
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:
|
func load_into(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
||||||
last_error = ""
|
last_error = ""
|
||||||
if manager == null or not manager.has_method("restore_state"):
|
if manager == null:
|
||||||
return _fail("Save target cannot restore simulation state")
|
return _fail("Save target is missing")
|
||||||
if not _is_valid_slot_name(slot_name):
|
if not _is_valid_slot_name(slot_name):
|
||||||
return _fail("Invalid save slot name")
|
return _fail("Invalid save slot name")
|
||||||
|
|
||||||
var final_path := get_slot_path(slot_name)
|
var final_path := get_slot_path(slot_name)
|
||||||
var record := _read_record(final_path)
|
var payload := _read_payload(final_path)
|
||||||
if record == null:
|
if payload.is_empty():
|
||||||
record = _read_record(final_path + ".bak")
|
payload = _read_payload(final_path + ".bak")
|
||||||
if record == null:
|
if payload.is_empty():
|
||||||
return _fail("Save slot is missing, invalid, or unsupported")
|
return _fail("Save slot is missing, invalid, or unsupported")
|
||||||
if not bool(manager.restore_state(record)):
|
if payload.has("manifest"):
|
||||||
return _fail("Simulation refused the validated save record")
|
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
|
return true
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +110,10 @@ func has_slot(slot_name: String = DEFAULT_SLOT) -> bool:
|
|||||||
if not _is_valid_slot_name(slot_name):
|
if not _is_valid_slot_name(slot_name):
|
||||||
return false
|
return false
|
||||||
var final_path := get_slot_path(slot_name)
|
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:
|
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")
|
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):
|
if not FileAccess.file_exists(path):
|
||||||
return null
|
return {}
|
||||||
var file := FileAccess.open(path, FileAccess.READ)
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
if file == null:
|
if file == null:
|
||||||
return null
|
return {}
|
||||||
if file.get_length() > MAX_SAVE_BYTES:
|
if file.get_length() > MAX_SAVE_BYTES:
|
||||||
file.close()
|
file.close()
|
||||||
return null
|
return {}
|
||||||
var json_text := file.get_as_text()
|
var json_text := file.get_as_text()
|
||||||
file.close()
|
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:
|
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
|
||||||
@@ -47,8 +47,11 @@ func _test_core_pack() -> void:
|
|||||||
"Core professions should enumerate by stable ID"
|
"Core professions should enumerate by stable ID"
|
||||||
)
|
)
|
||||||
_check(
|
_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(
|
_check(
|
||||||
_enemy_ids(catalog.get_enemies()) == [&"enemy_boar", &"enemy_raider", &"enemy_wolf"],
|
_enemy_ids(catalog.get_enemies()) == [&"enemy_boar", &"enemy_raider", &"enemy_wolf"],
|
||||||
@@ -102,14 +105,22 @@ func _test_deterministic_enumeration_and_dependencies() -> void:
|
|||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
_item_ids(catalog.get_items())
|
_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"
|
"Merged item enumeration should be globally stable-ID sorted"
|
||||||
)
|
)
|
||||||
var returned_items := catalog.get_items()
|
var returned_items := catalog.get_items()
|
||||||
returned_items.clear()
|
returned_items.clear()
|
||||||
_check(
|
_check(
|
||||||
catalog.get_items().size() == 6,
|
catalog.get_items().size() == 7,
|
||||||
"Callers should not be able to mutate the catalog's ordered arrays"
|
"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
|
||||||
@@ -17,7 +17,13 @@ func _run() -> void:
|
|||||||
|
|
||||||
for step in 8:
|
for step in 8:
|
||||||
manager.simulate_tick()
|
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_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.save(manager), "A valid simulation should save atomically")
|
||||||
_check(store.has_slot(), "The validated quicksave should be discoverable")
|
_check(store.has_slot(), "The validated quicksave should be discoverable")
|
||||||
|
|
||||||
@@ -27,11 +33,28 @@ func _run() -> void:
|
|||||||
manager.get_state_checksum() != saved_checksum,
|
manager.get_state_checksum() != saved_checksum,
|
||||||
"The scenario should change after the save point"
|
"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(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(
|
_check(
|
||||||
manager.get_state_checksum() == saved_checksum,
|
manager.get_state_checksum() == saved_checksum,
|
||||||
"Loading should restore the exact saved simulation"
|
"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 final_path := store.get_slot_path()
|
||||||
var backup_path := final_path + ".bak"
|
var backup_path := final_path + ".bak"
|
||||||
@@ -43,19 +66,187 @@ func _run() -> void:
|
|||||||
store.load_into(manager),
|
store.load_into(manager),
|
||||||
"A preserved previous save should recover an interrupted replacement"
|
"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(
|
_check(
|
||||||
DirAccess.rename_absolute(backup_path, final_path) == OK,
|
DirAccess.rename_absolute(backup_path, final_path) == OK,
|
||||||
"Test should restore the primary save path"
|
"Test should restore the primary save path"
|
||||||
)
|
)
|
||||||
|
|
||||||
var unchanged_checksum: String = manager.get_state_checksum()
|
var legacy_slot := "legacy_v16"
|
||||||
var file := FileAccess.open(final_path, FileAccess.WRITE)
|
var legacy_path := store.get_slot_path(legacy_slot)
|
||||||
file.store_string('{"schema":"broken"}')
|
var legacy_local_checksum: String = manager.get_state_checksum()
|
||||||
file.close()
|
var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE)
|
||||||
_check(not store.load_into(manager), "Invalid save data should be rejected")
|
_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(
|
_check(
|
||||||
manager.get_state_checksum() == unchanged_checksum,
|
store.load_into(manager, legacy_slot),
|
||||||
"A rejected save must not partially mutate the simulation"
|
"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")
|
_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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user