feat: integrate regional caravan persistence

This commit is contained in:
Rijad Zuzo
2026-08-23 17:20:41 +02:00
parent 49c5bda651
commit 52307f1306
24 changed files with 1939 additions and 92 deletions
+13 -12
View File
@@ -21,10 +21,11 @@ own persistent quantities, travel completion, or quest progress.
The long-term direction is one regional simulation containing settlements, 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
+20 -8
View File
@@ -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
+1 -1
View File
@@ -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
+11 -4
View File
@@ -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
View File
@@ -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
+16 -8
View File
@@ -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
+116 -1
View File
@@ -12,6 +12,9 @@ const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") const 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
+10
View File
@@ -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"
+3 -2
View File
@@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=94 format=3] [gd_resource type="Resource" script_class="SimulationContentPack" load_steps=95 format=3]
[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"] [ext_resource type="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")]
+64 -20
View File
@@ -15,16 +15,36 @@ func _init(directory: String = DEFAULT_DIRECTORY) -> void:
func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool: 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
+15 -4
View File
@@ -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
+198 -7
View File
@@ -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
+280
View File
@@ -0,0 +1,280 @@
extends GutTest
const MANAGER_SCRIPT := preload("res://simulation/SimulationManager.gd")
var manager: Node
func before_each() -> void:
manager = MANAGER_SCRIPT.new()
add_child_autofree(manager)
func test_canonical_manifest_round_trips_defensively() -> void:
var local_state: SimulationStateRecord = manager.create_state_record()
var regional_facade := RegionalSimulationFacade.create_jajce_route()
var manifest := SimulationSaveManifest.create(local_state, regional_facade)
assert_not_null(manifest)
var saved := manifest.to_dictionary()
var expected_fields := [
"local_checksum",
"local_state",
"regional_checksum",
"regional_state",
"schema",
"schema_version",
]
var saved_fields := saved.keys()
saved_fields.sort()
assert_eq(saved_fields, expected_fields)
assert_eq(saved["schema"], SimulationSaveManifest.SCHEMA_NAME)
assert_eq(saved["schema_version"], SimulationSaveManifest.SCHEMA_VERSION)
assert_eq(
saved["local_checksum"],
SimulationSaveManifest._checksum_dictionary(saved["local_state"]),
)
assert_eq(
saved["regional_checksum"],
SimulationSaveManifest._checksum_dictionary(saved["regional_state"]),
)
var restored := SimulationSaveManifest.from_dictionary(saved)
var restored_json := SimulationSaveManifest.from_json(manifest.to_json())
assert_not_null(restored)
assert_not_null(restored_json)
assert_eq(restored.to_dictionary(), saved)
assert_eq(restored.checksum(), manifest.checksum())
assert_eq(restored_json.checksum(), manifest.checksum())
assert_not_same(manifest.get_local_state(), local_state)
assert_not_same(manifest.get_regional_facade(), regional_facade)
local_state.simulation["tick_count"] = 99
var copied_local := manifest.get_local_state()
copied_local.simulation["tick_count"] = 77
assert_eq(int(manifest.get_local_state().simulation["tick_count"]), 0)
var copied_regional := manifest.get_regional_facade().to_dictionary()
copied_regional["last_processed_tick"] = 88
assert_eq(manifest.get_regional_facade().get_last_processed_tick(), 0)
func test_parser_rejects_missing_extra_and_wrong_manifest_fields() -> void:
var valid := _valid_manifest_dictionary()
for field: String in SimulationSaveManifest.MANIFEST_FIELDS:
var missing := valid.duplicate(true)
missing.erase(field)
assert_null(SimulationSaveManifest.from_dictionary(missing), "Missing %s" % field)
var extra := valid.duplicate(true)
extra["unexpected"] = true
assert_null(SimulationSaveManifest.from_dictionary(extra))
var wrong_schema := valid.duplicate(true)
wrong_schema["schema"] = &"the_steward.save_manifest"
assert_null(SimulationSaveManifest.from_dictionary(wrong_schema))
var wrong_version := valid.duplicate(true)
wrong_version["schema_version"] = "1"
assert_null(SimulationSaveManifest.from_dictionary(wrong_version))
var wrong_float_version := valid.duplicate(true)
wrong_float_version["schema_version"] = 1.0
assert_null(SimulationSaveManifest.from_dictionary(wrong_float_version))
var wrong_local := valid.duplicate(true)
wrong_local["local_state"] = []
assert_null(SimulationSaveManifest.from_dictionary(wrong_local))
var wrong_regional := valid.duplicate(true)
wrong_regional["regional_state"] = "regional"
assert_null(SimulationSaveManifest.from_dictionary(wrong_regional))
var wrong_local_checksum := valid.duplicate(true)
wrong_local_checksum["local_checksum"] = 1
assert_null(SimulationSaveManifest.from_dictionary(wrong_local_checksum))
var wrong_regional_checksum := valid.duplicate(true)
wrong_regional_checksum["regional_checksum"] = []
assert_null(SimulationSaveManifest.from_dictionary(wrong_regional_checksum))
assert_null(SimulationSaveManifest.from_json("[]"))
assert_null(SimulationSaveManifest.from_json("not json"))
func test_nested_tampering_and_checksum_mismatches_fail_closed() -> void:
var valid := _valid_manifest_dictionary()
var local_checksum_mismatch := valid.duplicate(true)
local_checksum_mismatch["local_checksum"] = "0".repeat(64)
assert_null(SimulationSaveManifest.from_dictionary(local_checksum_mismatch))
var regional_checksum_mismatch := valid.duplicate(true)
regional_checksum_mismatch["regional_checksum"] = "f".repeat(64)
assert_null(SimulationSaveManifest.from_dictionary(regional_checksum_mismatch))
var tampered_local := valid.duplicate(true)
tampered_local["local_state"]["simulation"]["seed"] = 4004
assert_null(SimulationSaveManifest.from_dictionary(tampered_local))
var tampered_regional := valid.duplicate(true)
tampered_regional["regional_state"]["last_processed_tick"] = 1
assert_null(SimulationSaveManifest.from_dictionary(tampered_regional))
var nested_extra := valid.duplicate(true)
nested_extra["local_state"]["unexpected"] = true
nested_extra["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
nested_extra["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(nested_extra))
assert_null(SimulationSaveManifest.from_json(JSON.stringify(nested_extra)))
func test_local_seed_and_tick_require_exact_integer_types() -> void:
var valid := _valid_manifest_dictionary()
var invalid_boundaries: Array[Dictionary] = [
{"field": "seed", "value": "1337"},
{"field": "seed", "value": 1337.5},
{"field": "tick_count", "value": "0"},
{"field": "tick_count", "value": 0.5},
]
for invalid_boundary: Dictionary in invalid_boundaries:
var field := String(invalid_boundary["field"])
var value: Variant = invalid_boundary["value"]
var malformed := valid.duplicate(true)
malformed["local_state"]["simulation"][field] = value
malformed["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
malformed["local_state"]
)
assert_null(
SimulationSaveManifest.from_dictionary(malformed),
"Dictionary parser should reject %s=%s" % [field, value],
)
assert_null(
SimulationSaveManifest.from_json(JSON.stringify(malformed)),
"JSON parser should reject %s=%s" % [field, value],
)
var local_state: SimulationStateRecord = manager.create_state_record()
local_state.simulation[field] = value
assert_null(
SimulationSaveManifest.create(
local_state, RegionalSimulationFacade.create_jajce_route()
),
"Manifest creation should reject %s=%s" % [field, value],
)
func test_json_parser_rejects_nonnumeric_regional_ledger_values() -> void:
var string_ledger := _valid_manifest_dictionary()
_set_regional_stockpile_value(
string_ledger["regional_state"],
RegionalSimulationFacade.SETTLEMENT_TRAVNIK,
"4",
)
assert_null(SimulationSaveManifest.from_json(JSON.stringify(string_ledger)))
var boolean_ledger := _valid_manifest_dictionary()
_set_regional_stockpile_value(
boolean_ledger["regional_state"],
RegionalSimulationFacade.SETTLEMENT_JAJCE,
false,
)
var canonical_regional_state: Dictionary = boolean_ledger["regional_state"].duplicate(true)
_set_regional_stockpile_value(
canonical_regional_state,
RegionalSimulationFacade.SETTLEMENT_JAJCE,
0.0,
)
boolean_ledger["regional_checksum"] = SimulationSaveManifest._checksum_dictionary(
canonical_regional_state
)
assert_null(SimulationSaveManifest.from_json(JSON.stringify(boolean_ledger)))
func test_world_location_and_tick_must_match_the_regional_boundary() -> void:
var valid := _valid_manifest_dictionary()
var wrong_world := valid.duplicate(true)
wrong_world["local_state"]["simulation"]["world_id"] = "regional_elsewhere"
wrong_world["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_world["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_world))
var wrong_location := valid.duplicate(true)
wrong_location["local_state"]["simulation"]["location_id"] = String(
RegionalSimulationFacade.LOCATION_TRAVNIK
)
wrong_location["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_location["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_location))
var wrong_seed := valid.duplicate(true)
wrong_seed["local_state"]["simulation"]["seed"] = (
RegionalSimulationFacade.DEFAULT_WORLD_SEED + 1
)
wrong_seed["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_seed["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_seed))
var wrong_regional_seed := valid.duplicate(true)
wrong_regional_seed["regional_state"]["world_seed"] = (
RegionalSimulationFacade.DEFAULT_WORLD_SEED + 1
)
wrong_regional_seed["regional_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_regional_seed["regional_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_regional_seed))
var wrong_tick := valid.duplicate(true)
wrong_tick["local_state"]["simulation"]["tick_count"] = 1
wrong_tick["local_checksum"] = SimulationSaveManifest._checksum_dictionary(
wrong_tick["local_state"]
)
assert_null(SimulationSaveManifest.from_dictionary(wrong_tick))
assert_null(
SimulationSaveManifest.create(
manager.create_state_record(),
RegionalSimulationFacade.create_jajce_route(
RegionalSimulationFacade.DEFAULT_WORLD_SEED, 1
)
)
)
assert_null(
SimulationSaveManifest.create(
manager.create_state_record(),
RegionalSimulationFacade.create_jajce_route(
RegionalSimulationFacade.DEFAULT_WORLD_SEED + 1, 0
)
)
)
var negative_seed_local: SimulationStateRecord = manager.create_state_record()
negative_seed_local.simulation["seed"] = -42
assert_not_null(
SimulationSaveManifest.create(
negative_seed_local, RegionalSimulationFacade.create_jajce_route(-42, 0)
)
)
manager.simulate_tick()
var synchronized := SimulationSaveManifest.create(
manager.create_state_record(),
RegionalSimulationFacade.create_jajce_route(RegionalSimulationFacade.DEFAULT_WORLD_SEED, 1)
)
assert_not_null(synchronized)
assert_eq(synchronized.get_regional_facade().get_last_processed_tick(), 1)
assert_eq(int(synchronized.get_local_state().simulation["tick_count"]), 1)
func _valid_manifest_dictionary() -> Dictionary:
var manifest := SimulationSaveManifest.create(
manager.create_state_record(), RegionalSimulationFacade.create_jajce_route()
)
assert_not_null(manifest)
return manifest.to_dictionary()
func _set_regional_stockpile_value(
regional_state: Dictionary, settlement_id: StringName, value: Variant
) -> void:
for settlement: Dictionary in regional_state["caravan_service"]["world_state"]["settlements"]:
if StringName(settlement["settlement_id"]) == settlement_id:
settlement["stockpile_ledger"][String(RegionalSimulationFacade.TRADE_GOODS)] = value
return
assert_true(false, "Expected regional settlement '%s'" % settlement_id)
@@ -0,0 +1 @@
uid://cradryxj45n86