diff --git a/docs/DEVELOPER_INDEX.md b/docs/DEVELOPER_INDEX.md index 41d2dab..7b50288 100644 --- a/docs/DEVELOPER_INDEX.md +++ b/docs/DEVELOPER_INDEX.md @@ -21,10 +21,11 @@ own persistent quantities, travel completion, or quest progress. The long-term direction is one regional simulation containing settlements, routes, caravans, named people, aggregate cohorts, and polities. The current repository has the regional records, deterministic scheduler, one exact -caravan transfer service, chunked persistence, and a 20-caravan structural -benchmark. It does not yet run that regional service as the production -`SimulationManager` facade or provide a complete market, settlement-growth, or -kingdom gameplay loop. That distinction is important when extending the game. +caravan transfer service, a production facade for one inbound Jajce route, a +combined local/regional save manifest, chunked persistence, and a 20-caravan +structural benchmark. It does not yet provide a complete market, +settlement-growth, or kingdom gameplay loop. That distinction is important +when extending the game. ## Start here @@ -53,9 +54,9 @@ kingdom gameplay loop. That distinction is important when extending the game. | Situations/quests | Definition-driven bounded situations, derived progress, journal metadata, commitments and idempotent outcomes | The initial Jajce slice is bounded; it is not a universal objective scripting language | | Dialogue | Deterministic semantic `ConversationService`, authored intent/template resources, Dialogue Manager v3 presenter | Dialogue Manager renders planned turns; it is not an authority or save format | | Entities | Shared definition → instance/state → presentation contracts for resources, animals, enemies, items, storage | New behavior profiles and genuinely new mechanics still need typed code and tests | -| Regional kernel | Primitive records, due-job heap, keyed random, analytical updates, caravan proof, chunk/file store | Regional service and 20-caravan trade are currently integration fixtures, not the live Jajce loop | +| Regional kernel | Primitive records, due-job heap, keyed random, analytical updates, caravan service, production facade, chunk/file store | One authored inbound route is live; the 20-caravan economy remains a structural fixture, not the Jajce market | | Presentation | Active-world adapters, context registries, relevance cap of 40, navigation budget of 2/frame, High/Balanced/Low profiles | Hardware frame-time/VRAM evidence is still pending; structural benchmarks are not rendered claims | -| Persistence | Local save schema v16 with migrations and scope preflight; regional chunk generations with recovery | No save menu/profile system; regional chunk store is not yet the local `SaveSlotStore` backend | +| Persistence | Combined manifest v1 containing unchanged local schema v16 plus the canonical regional facade; regional chunk generations with recovery | No save menu/profile system; regional chunk/history generations are not yet the `SaveSlotStore` backend | ## The authority pipeline @@ -106,9 +107,10 @@ relationship, or reward that the event claims happened. - `simulation/events/`, `knowledge/`, `relationships/`, `situations/`, and `dialogue/` — causal history and emergent narrative layers. - `simulation/state/` — versioned primitive records and validation. -- `simulation/persistence/` — local quicksave safety. -- `simulation/regional/` — regional records, scheduler, caravan proof, chunk - codec, and file generations. +- `simulation/persistence/` — combined quicksave manifest and atomic slot + replacement, plus regional chunk codecs and generations. +- `simulation/regional/` — production facade, regional records, scheduler, + caravan transaction, retention planning, and scale contracts. ### Loaded world and presentation @@ -161,12 +163,11 @@ are used when an existing save is loaded. The following are deliberately **not** claimed as complete: -- wire `RegionalCaravanService` into the production regional facade and local - save manifest; +- integrate event retention/rollups/chunked history into one atomic, + lazily loaded long-horizon save; - run 20 real caravan trades across five settlement economies with atomic cargo/stock ledgers; - replace local unloaded travel freezes with authoritative route-time work; -- integrate event retention/rollups/chunked history into one long-horizon save; - add settlement projects, cohort promotion/demotion, offices, diplomacy, armies, campaigns, and aggregate conflict; - add visual relevance streaming to the live world view rather than only the diff --git a/docs/FEATURE_REGIONAL_SIMULATION_AND_PERSISTENCE.md b/docs/FEATURE_REGIONAL_SIMULATION_AND_PERSISTENCE.md index ea7f845..17bb33e 100644 --- a/docs/FEATURE_REGIONAL_SIMULATION_AND_PERSISTENCE.md +++ b/docs/FEATURE_REGIONAL_SIMULATION_AND_PERSISTENCE.md @@ -2,9 +2,9 @@ The regional package is the scale foundation for one authority spanning settlements, routes, caravans, named people, aggregate cohorts, and polities. -It is intentionally additive: the current Jajce `SimulationManager` remains -the playable local facade while the regional contracts prove deterministic -unloaded work and conservation in isolation. +The production `SimulationManager` now owns a bounded regional facade for one +inbound Jajce route, while the broader regional contracts continue to prove +deterministic unloaded work and conservation before wider integration. ## Regional record model @@ -63,6 +63,12 @@ no partial mutation. `to_dictionary()`, `from_dictionary()`, and `checksum()` provide deterministic save/restore. The test runs always-loaded, never-loaded, and load/unload presentation modes through the same authoritative service. +`RegionalSimulationFacade` is the first production consumer. It owns one +authored Travnik-to-Jajce route, advances before each committed local tick, and +exposes one bounded delivery command. The cargo is a separate catalogued +`regional_trade_goods` item with no local storage tag, so this proof does not +shadow or duplicate the pantry and woodpile economy. + ## Chunked persistence `RegionalChunkedPersistence` captures the service into a manifest plus fixed @@ -89,11 +95,20 @@ primitive-tree bounds. ## Current integration boundary -The regional package is not yet the production authority for `main.tscn`: +`main.tscn` now advances the regional facade and `SaveSlotStore` writes a +combined `SimulationSaveManifest` containing the unchanged local v16 record +and canonical regional envelope. Both checksums plus world, seed, location, +and tick alignment are validated before either authority is restored. Raw +local v16 slots remain compatible and create an idle regional facade; +explicit non-Jajce adapter scopes keep writing local v16 until they have a +matching regional facade. Those scopes cannot dispatch a Jajce delivery, and +local-only saving fails closed if regional state is no longer reconstructibly +idle. -- Jajce local saves still use `SaveSlotStore` and `SimulationStateRecord` v16; -- `RegionalCaravanService` is a complete isolated transfer proof, not the live - economy's 20-trade scheduler; +The remaining boundary is intentionally narrow: + +- the facade has one callable inbound route, not the live economy's 20-trade + market scheduler; - event retention/rollups and regional chunk files are not yet one integrated long-horizon save transaction; - loaded/unloaded local NPC travel still has the old active-visual boundary; @@ -101,8 +116,8 @@ The regional package is not yet the production authority for `main.tscn`: aggregate conflict remain planned. Do not create one `SimulationManager` per settlement or caravan. The next -production step is an explicit regional facade that delegates to these records -and services, while the active adapter presents only a bounded relevant context. +production step is to publish retained raw events, daily rollups, and chunked +regional history as one atomic, lazily loaded transaction behind the facade. ## Scale evidence diff --git a/docs/FEATURE_SAVE_SCHEMA_AND_MIGRATIONS.md b/docs/FEATURE_SAVE_SCHEMA_AND_MIGRATIONS.md index 1fddf06..22555d4 100644 --- a/docs/FEATURE_SAVE_SCHEMA_AND_MIGRATIONS.md +++ b/docs/FEATURE_SAVE_SCHEMA_AND_MIGRATIONS.md @@ -64,11 +64,22 @@ catch corruption. ## Local file safety -`SaveSlotStore` validates the complete JSON before replacement, restricts slot -names and size, preserves a previous file, and can recover a backup after an -interrupted replacement. `F5`/`F9` use the current quicksave path in `main.tscn`. -The player transform and presentation-only scene state are intentionally not -saved. +`SimulationSaveManifest` v1 combines the unchanged local v16 record and the +canonical `RegionalSimulationFacade` envelope. It stores an independent +checksum for each authority and cross-validates world, seed, active location, +and processed tick. A rejected manifest cannot partially replace local or +regional state. + +`SaveSlotStore` validates the complete manifest before replacement, restricts +slot names and size, preserves a previous file, and can recover a backup after +an interrupted replacement. It also accepts existing raw local v16 files and +bootstraps an idle regional facade at the restored seed and tick. Explicit +non-Jajce adapter scopes continue writing raw local v16 records until a +matching regional facade is implemented. This fallback is permitted only while +the regional facade equals its deterministic idle bootstrap; a local-only save +fails closed rather than discarding live regional state. `F5`/`F9` use the +current quicksave path in `main.tscn`. The player transform and presentation-only +scene state are intentionally not saved. ## Regional persistence boundary @@ -79,9 +90,10 @@ per-file checksums. The file store uses immutable generations, a current pointer, recovery scanning, and conservative pruning. Partial active-location loads are metadata-only and cannot silently resurrect a removed location. -This regional codec is not yet the backend for local `SaveSlotStore`. Integrate -it only after the production regional facade, event retention, and lazy context -loading have one atomic manifest contract. +This regional chunk codec is not yet the backend for `SaveSlotStore`. The +production facade and combined quicksave manifest are complete; retained raw +events, rollups, chunk generations, and lazy context loading still need one +atomic long-horizon publication contract. ## How to change state safely diff --git a/docs/FEATURE_TESTING_AND_BENCHMARKS.md b/docs/FEATURE_TESTING_AND_BENCHMARKS.md index 069d2b4..78d7159 100644 --- a/docs/FEATURE_TESTING_AND_BENCHMARKS.md +++ b/docs/FEATURE_TESTING_AND_BENCHMARKS.md @@ -77,7 +77,7 @@ For a new system, add tests for: ## Current evidence and gaps -The current full suite passes 154/154 tests with 2,047 assertions. Baseline 03 +The current full suite passes 180/180 tests with 2,258 assertions. Baseline 03 measures 600 full-fidelity data-only NPC/combatant records at about 90.6 ticks/s on the reviewed development host. The regional baseline proves the target record counts and scheduler parity. Neither measures a complete regional diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 800fd24..e58596c 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -1,10 +1,11 @@ # The Steward — Learning and Reusable-Systems Roadmap -> **Current implementation checkpoint (2026-08-13):** The roadmap below is a +> **Current implementation checkpoint (2026-08-23):** The roadmap below is a > chronological learning record. The current code has completed the authored > content/catalog, shared target/command, event/social, situation/dialogue, > entity-family, context-scoping, and first regional record/caravan/chunk -> slices. For a source-based status matrix and the remaining integration work, +> slices, including one production facade and combined save manifest. For a +> source-based status matrix and the remaining integration work, > start with [`DEVELOPER_INDEX.md`](DEVELOPER_INDEX.md). Do not infer current > behavior from an older milestone paragraph when the index or code disagrees. @@ -43,8 +44,8 @@ becomes knowledge, generated semantic dialogue, a journal projection, and an event-backed social commitment whose ordinary deposit outcome survives save/load. Definition packs now cover reusable items, resources, storage, enemies, and animals. Regional records, deterministic scheduled work, bounded -presentation relevance, and the first authoritative caravan transfer form the -next world-slice foundation. See +presentation relevance, and the first authoritative caravan transfer now feed +one production facade and combined local/regional save boundary. See [ADR 0001](decisions/0001-simulation-authority-boundary.md), [ADR 0002](decisions/0002-emergent-world-content-and-narrative.md), and the [regional simulation contract](REGIONAL_SIMULATION.md). @@ -1107,6 +1108,12 @@ resolution, and personal request accept/decline. Recently completed: +- `Regional Jajce Facade 13`: `SimulationManager` now advances one authored + Travnik-to-Jajce caravan route through the regional service, and an atomic + combined manifest preserves both local v16 and regional authority. Separate + regional trade goods prevent duplicate pantry/woodpile ownership; tamper, + legacy-slot, rollback, conservation, and deterministic continuation + regressions define the boundary before broader markets or chunked history. - `Jajce Villager Field Note 12`: a separate player-facing note selects the nearest loaded living villager and shows live name, action, task state, exact target, carried inventory, and a currently matching decision reason. It diff --git a/docs/REGIONAL_SIMULATION.md b/docs/REGIONAL_SIMULATION.md index ec4eb62..0b404c3 100644 --- a/docs/REGIONAL_SIMULATION.md +++ b/docs/REGIONAL_SIMULATION.md @@ -1,10 +1,11 @@ # Regional simulation contract -> **Implementation status (2026-08-13):** The record, scheduler, caravan, +> **Implementation status (2026-08-23):** The record, scheduler, caravan, > chunk, file-generation, relevance, and navigation-budget contracts are -> implemented and tested. They are still an additive regional package; the -> playable Jajce `SimulationManager` and `SaveSlotStore` remain the local -> production authority until the integrations listed at the end land. +> implemented and tested. `SimulationManager` now owns a production regional +> facade for one inbound Jajce route, and `SaveSlotStore` persists local and +> regional authority in one checked manifest. The scale economy and chunked +> long-horizon history remain separate integrations. The regional layer extends the Jajce simulation without multiplying scene-tree managers. One authority owns every loaded and unloaded location, settlement, @@ -38,12 +39,10 @@ validated transactionally before a replacement state is published. Canonical serialization sorts record families and IDs, making checksums independent of construction order. -The current local `SimulationStateRecord` remains the v16 Jajce save boundary. -Regional state is deliberately carried in its own versioned service envelope -until one playable route consumes it; this avoids placing speculative regional -fields in every local save. The merge into the production save manifest must -retain this canonical regional payload rather than copying its fields into -scene nodes. +The current local `SimulationStateRecord` remains the unchanged v16 Jajce +record. `SimulationSaveManifest` v1 combines that record with the canonical +`RegionalSimulationFacade` envelope and checksums both authorities. Regional +fields are not copied into local records or scene nodes. ## Deterministic work @@ -92,6 +91,30 @@ never-loaded, and load/unload executions must preserve cargo, headcount, facts, arrival tick, queue state, and checksum. Nearby visuals may interpolate route progress; they do not report elapsed travel completion. +## Production facade and combined save + +`RegionalSimulationFacade` is now owned by the production `SimulationManager`. +It bootstraps an authored Travnik-to-Jajce route and advances the same +`RegionalCaravanService` before each committed local tick. Its bounded public +command can depart one exact delivery; arrival remains scheduled simulation +work and does not depend on a loaded caravan visual. + +The route transports `regional_trade_goods`, a catalogued item with no local +storage tag. Jajce starts with no regional stock and Travnik starts with four +units, so the facade cannot duplicate the playable pantry's food or the +woodpile's wood. Local and regional event-ID spaces remain deliberately +separate until an explicit cross-domain history contract exists. + +`SimulationSaveManifest` stores exactly the local v16 record, its checksum, the +regional facade envelope, its checksum, and the manifest schema/version. It +rejects checksum tampering and mismatched world, seed, active location, or +tick. Combined restore is transactional across both authorities. Existing raw +local v16 quicksaves remain loadable and bootstrap an idle regional route at +the restored seed and tick. Explicit non-Jajce adapter scopes continue to use +their local v16 boundary until a matching regional facade exists. That fallback +is allowed only while the regional facade equals its deterministic idle +bootstrap; Jajce delivery commands are rejected outside the combined scope. + ## Event retention and daily rollups `WorldEventRetentionPlanner` is a pure, deterministic query over a complete @@ -157,14 +180,12 @@ active-world and hardware captures. Build in this order, keeping each step behind conservation and continuation tests: -1. make the Jajce route proof callable from the production regional facade and - save manifest; -2. integrate the retention/rollup and chunk codecs as one atomic, +1. integrate the retention/rollup and chunk codecs as one atomic, lazily loaded history transaction; -3. schedule 20 ordinary caravan trades across five settlement economies; -4. promote/demote presentation and population fidelity while pinning named and +2. schedule 20 ordinary caravan trades across five settlement economies; +3. promote/demote presentation and population fidelity while pinning named and causally referenced people; -5. add data-defined settlement projects, tier promotion, offices, diplomacy, +4. add data-defined settlement projects, tier promotion, offices, diplomacy, armies, campaigns, and aggregate conflict. Context-owned world-target registries and the first shared player/NPC activity diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 47bc82e..31d3cc1 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -451,12 +451,20 @@ replayed after restore. ## Local quicksave boundary -`SaveSlotStore` writes the existing versioned local JSON record to -`user://saves/quicksave.json`. It validates the serialized record before -replacement, limits file size, restricts slot names, preserves the previous -file during replacement, and can recover that backup if replacement is -interrupted. Loading parses and validates the complete record before mutating -the simulation. +`SaveSlotStore` writes `SimulationSaveManifest` v1 to +`user://saves/quicksave.json`. The manifest contains the unchanged local v16 +record and canonical regional facade envelope, with an independent checksum +for each. Restore also requires world, seed, active location, and tick to agree +across both authorities, and rolls both back if local restoration fails. + +The store validates the complete serialized manifest before replacement, +limits file size, restricts slot names, preserves the previous file during +replacement, and can recover that backup if replacement is interrupted. Raw +local v16 quicksaves remain loadable; they bootstrap an idle regional facade +at the restored seed and tick. Explicit non-Jajce adapter scopes continue +writing local v16 records until a matching regional facade exists. Local-only +saving is allowed only while regional state equals its deterministic idle +bootstrap; otherwise it fails closed rather than discarding authority. In `main.tscn`, F5 saves and F9 loads this slot. Restoration rebuilds active NPC visuals from authoritative state. @@ -476,8 +484,8 @@ This phase does not yet provide: - persistence of a currently open dialogue UI/turn. Semantic conversation history and commitment authority persist, while a balloon is regenerated after restore; -- production integration of the regional chunked save manifest, lazy location - loading, or aggregate population promotion/persistence; +- production integration of regional retained/rolled-up chunk history, lazy + location loading, or aggregate population promotion/persistence; - persistence for the player transform or presentation-only scene state. Those features should build on this boundary rather than inventing parallel diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index cc12395..fbcb15e 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -12,6 +12,9 @@ const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd") const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd") const ConflictSystemScript := preload("res://simulation/conflict/ConflictSystem.gd") +const RegionalSimulationFacadeScript := preload( + "res://simulation/regional/RegionalSimulationFacade.gd" +) signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName) signal village_changed(village: SimVillage) @@ -84,6 +87,8 @@ var conflict_system := ConflictSystemScript.new() var player_quest_system := PlayerQuestSystem.new() var player_needs := PlayerNeedsSystem.new() var player_negotiation := PlayerNegotiationSystem.new() +var _regional_simulation: RegionalSimulationFacade +var _restoring_save_manifest := false var _last_player_tier := PlayerStandingRecord.TIER_STRANGER var storage_states: Dictionary: get: @@ -165,6 +170,9 @@ func _ready() -> void: clock = SimulationClock.new(tick_interval) clock.cycle_duration_seconds = cycle_duration_seconds clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval) + if not _initialize_regional_simulation(tick_count): + set_process(false) + return village.debug_logs = debug_logs economy.configure(village, debug_logs) animal_care.configure(economy, active_world_adapter) @@ -248,7 +256,17 @@ func get_wander_offset(npc_id: int) -> Vector3: func simulate_tick() -> void: - tick_count += 1 + var next_tick := tick_count + 1 + if _regional_simulation == null or not _regional_simulation.advance_to_tick(next_tick): + var regional_error := ( + _regional_simulation.get_last_error() + if _regional_simulation != null + else "regional facade is missing" + ) + push_error("SimulationManager: regional tick failed: " + regional_error) + set_process(false) + return + tick_count = next_tick if debug_logs: print("--- Tick ", tick_count, " ---") animal_care.advance(tick_count) @@ -2408,6 +2426,34 @@ func get_state_checksum() -> String: return create_state_record().to_json().sha256_text() +func get_regional_state_checksum() -> String: + return _regional_simulation.checksum() if _regional_simulation != null else "" + + +func get_world_state_checksum() -> String: + var manifest := create_save_manifest() + return manifest.checksum() if manifest != null else "" + + +func get_regional_snapshot() -> Dictionary: + return ( + _regional_simulation.to_dictionary().duplicate(true) if _regional_simulation != null else {} + ) + + +func request_regional_jajce_delivery( + amount: float = 1.0, presentation_mode: StringName = &"never" +) -> bool: + if ( + not uses_combined_save_manifest() + or _regional_simulation == null + or not is_finite(amount) + or amount <= 0.0 + ): + return false + return _regional_simulation.depart_jajce_delivery(amount, presentation_mode) + + func get_latest_decision(npc_id: int) -> ActionSelectionResult: return latest_decisions.get(npc_id) @@ -2484,6 +2530,59 @@ func serialize_state() -> String: return create_state_record().to_json() +func uses_combined_save_manifest() -> bool: + var scope := event_log.get_scope() + return ( + StringName(scope.get("world_id", &"")) == SimulationIds.REGIONAL_WORLD_BOSNIA + and (StringName(scope.get("location_id", &"")) == SimulationIds.REGIONAL_LOCATION_JAJCE) + ) + + +func can_serialize_local_state_only() -> bool: + if uses_combined_save_manifest() or _regional_simulation == null: + return false + var idle_facade := RegionalSimulationFacadeScript.create_jajce_route( + simulation_seed, tick_count + ) + return idle_facade != null and idle_facade.checksum() == _regional_simulation.checksum() + + +func create_save_manifest() -> SimulationSaveManifest: + if _regional_simulation == null: + return null + return SimulationSaveManifest.create(create_state_record(), _regional_simulation) + + +func serialize_save_manifest() -> String: + var manifest := create_save_manifest() + return manifest.to_json() if manifest != null else "" + + +func restore_save_manifest(manifest: SimulationSaveManifest) -> bool: + if manifest == null: + return false + var local_candidate := manifest.get_local_state() + var regional_candidate := manifest.get_regional_facade() + if local_candidate == null or regional_candidate == null: + return false + var previous_local := create_state_record() + var previous_regional := _regional_simulation + _regional_simulation = regional_candidate + _restoring_save_manifest = true + var restored := restore_state(local_candidate) + _restoring_save_manifest = false + if restored: + _publish_restored_state() + return true + _regional_simulation = previous_regional + _restoring_save_manifest = true + var rolled_back := restore_state(previous_local) + _restoring_save_manifest = false + if not rolled_back: + push_error("SimulationManager: failed to roll back rejected save manifest") + return false + + func restore_state_from_json(json_text: String) -> bool: var record := SimulationStateRecord.from_json(json_text) if record == null: @@ -2578,8 +2677,24 @@ func restore_state(record: SimulationStateRecord) -> bool: conflict_system.register_npc_combatants(npcs) conflict_system.configure(economy, tick_interval) _sync_player_combatant() + if not _restoring_save_manifest and not _initialize_regional_simulation(tick_count): + return false + if not _restoring_save_manifest: + _publish_restored_state() + return true + + +func _publish_restored_state() -> void: village_changed.emit(village) state_restored.emit() + + +func _initialize_regional_simulation(current_tick: int) -> bool: + var facade := RegionalSimulationFacadeScript.create_jajce_route(simulation_seed, current_tick) + if facade == null: + push_error("SimulationManager: could not initialize the Jajce regional facade") + return false + _regional_simulation = facade return true diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index bb4923c..2ab977f 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -8,6 +8,15 @@ const WORLD_CORE := &"world_core" const LOCATION_JAJCE := &"settlement_jajce" const REGIONAL_WORLD_BOSNIA := &"regional_bosnia" const REGIONAL_LOCATION_JAJCE := &"location_jajce" +const REGIONAL_LOCATION_TRAVNIK := &"location_travnik" +const REGIONAL_SETTLEMENT_JAJCE := LOCATION_JAJCE +const REGIONAL_SETTLEMENT_TRAVNIK := &"settlement_travnik" +const REGIONAL_POLITY_JAJCE := &"polity_jajce" +const REGIONAL_POLITY_TRAVNIK := &"polity_travnik" +const REGIONAL_ROUTE_JAJCE_TRAVNIK := &"route_jajce_travnik" +const REGIONAL_CARAVAN_BLUE := &"group_blue_caravan" +const REGIONAL_PERSON_ANA := &"person_ana" +const REGIONAL_COHORT_CARRIERS := &"cohort_carriers" const ACTIVITY_GUARD_POST := &"guard_post" const ACTIVITY_STUDY_DESK := &"study_desk" const ACTION_HANDLER_ACTIVITY_METRIC_DELTA := &"activity_metric_delta" @@ -51,6 +60,7 @@ const TARGET_FREE := &"free" const RESOURCE_FOOD := &"food" const RESOURCE_WOOD := &"wood" +const RESOURCE_REGIONAL_TRADE_GOODS := &"regional_trade_goods" const ANIMAL_DUNJA := &"goat_dunja" const ANIMAL_ZORA := &"goat_zora" diff --git a/simulation/definitions/items/regional_trade_goods.tres b/simulation/definitions/items/regional_trade_goods.tres new file mode 100644 index 0000000..472e551 --- /dev/null +++ b/simulation/definitions/items/regional_trade_goods.tres @@ -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" diff --git a/simulation/definitions/packs/core.tres b/simulation/definitions/packs/core.tres index d26d5b8..f80f547 100644 --- a/simulation/definitions/packs/core.tres +++ b/simulation/definitions/packs/core.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=94 format=3] +[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=95 format=3] [ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"] [ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"] @@ -92,6 +92,7 @@ [ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_broken_hostility.tres" id="89_broken_hostility"] [ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_released_obligation.tres" id="90_released_obligation"] [ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_superseded_obligation.tres" id="91_superseded_obligation"] +[ext_resource type="Resource" path="res://simulation/definitions/items/regional_trade_goods.tres" id="93_regional_trade_goods"] [resource] script = ExtResource("1_pack") @@ -101,7 +102,7 @@ required_handler_ids = [&"activity_metric_delta"] actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")] professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")] capability_tags = [ExtResource("28_item_food"), ExtResource("29_item_medicine"), ExtResource("30_item_wood"), ExtResource("31_resource_harvestable"), ExtResource("32_storage_village")] -items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood"), ExtResource("27_herb")] +items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood"), ExtResource("27_herb"), ExtResource("93_regional_trade_goods")] resources = [ExtResource("33_berry_patch"), ExtResource("34_herb_patch"), ExtResource("35_tree")] storages = [ExtResource("36_apothecary"), ExtResource("37_pantry"), ExtResource("38_woodpile")] enemies = [ExtResource("24_raider"), ExtResource("25_wolf"), ExtResource("26_boar")] diff --git a/simulation/persistence/SaveSlotStore.gd b/simulation/persistence/SaveSlotStore.gd index bbb4636..ad2c3fb 100644 --- a/simulation/persistence/SaveSlotStore.gd +++ b/simulation/persistence/SaveSlotStore.gd @@ -15,16 +15,36 @@ func _init(directory: String = DEFAULT_DIRECTORY) -> void: func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool: last_error = "" - if manager == null or not manager.has_method("serialize_state"): - return _fail("Save source cannot serialize simulation state") + if ( + manager == null + or not ( + manager.has_method("serialize_save_manifest") or manager.has_method("serialize_state") + ) + ): + return _fail("Save source cannot serialize authoritative state") if not _is_valid_slot_name(slot_name): return _fail("Invalid save slot name") - var json_text: String = manager.serialize_state() + var use_manifest := manager.has_method("serialize_save_manifest") + if use_manifest and manager.has_method("uses_combined_save_manifest"): + use_manifest = bool(manager.call("uses_combined_save_manifest")) + if ( + not use_manifest + and manager.has_method("can_serialize_local_state_only") + and not bool(manager.call("can_serialize_local_state_only")) + ): + return _fail("Save source has regional authority outside its active manifest scope") + var json_text: String + if use_manifest: + json_text = String(manager.call("serialize_save_manifest")) + elif manager.has_method("serialize_state"): + json_text = String(manager.call("serialize_state")) + else: + return _fail("Save source cannot serialize its active authority scope") if json_text.to_utf8_buffer().size() > MAX_SAVE_BYTES: return _fail("Save exceeds the supported size limit") - if SimulationStateRecord.from_json(json_text) == null: - return _fail("Simulation produced an invalid save record") + if not _is_valid_save_json(json_text): + return _fail("Simulation produced an invalid save payload") if not _ensure_directory(): return false @@ -39,7 +59,7 @@ func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool: file.store_string(json_text) file.flush() file.close() - if _read_record(temporary_path) == null: + if _read_payload(temporary_path).is_empty(): _remove_if_present(temporary_path) return _fail("Temporary save failed validation") @@ -60,19 +80,29 @@ func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool: func load_into(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool: last_error = "" - if manager == null or not manager.has_method("restore_state"): - return _fail("Save target cannot restore simulation state") + if manager == null: + return _fail("Save target is missing") if not _is_valid_slot_name(slot_name): return _fail("Invalid save slot name") var final_path := get_slot_path(slot_name) - var record := _read_record(final_path) - if record == null: - record = _read_record(final_path + ".bak") - if record == null: + var payload := _read_payload(final_path) + if payload.is_empty(): + payload = _read_payload(final_path + ".bak") + if payload.is_empty(): return _fail("Save slot is missing, invalid, or unsupported") - if not bool(manager.restore_state(record)): - return _fail("Simulation refused the validated save record") + if payload.has("manifest"): + if not manager.has_method("restore_save_manifest"): + return _fail("Save target cannot restore a combined save manifest") + if not bool(manager.call("restore_save_manifest", payload["manifest"])): + return _fail("Simulation refused the validated save manifest") + elif payload.has("local_state"): + if not manager.has_method("restore_state"): + return _fail("Save target cannot restore legacy local state") + if not bool(manager.call("restore_state", payload["local_state"])): + return _fail("Simulation refused the validated legacy save record") + else: + return _fail("Save slot payload kind is unsupported") return true @@ -80,7 +110,10 @@ func has_slot(slot_name: String = DEFAULT_SLOT) -> bool: if not _is_valid_slot_name(slot_name): return false var final_path := get_slot_path(slot_name) - return _read_record(final_path) != null or _read_record(final_path + ".bak") != null + return ( + not _read_payload(final_path).is_empty() + or not _read_payload(final_path + ".bak").is_empty() + ) func delete_slot(slot_name: String = DEFAULT_SLOT) -> bool: @@ -97,18 +130,29 @@ func get_slot_path(slot_name: String = DEFAULT_SLOT) -> String: return save_directory.path_join(slot_name + ".json") -func _read_record(path: String) -> SimulationStateRecord: +func _read_payload(path: String) -> Dictionary: if not FileAccess.file_exists(path): - return null + return {} var file := FileAccess.open(path, FileAccess.READ) if file == null: - return null + return {} if file.get_length() > MAX_SAVE_BYTES: file.close() - return null + return {} var json_text := file.get_as_text() file.close() - return SimulationStateRecord.from_json(json_text) + var manifest := SimulationSaveManifest.from_json(json_text) + if manifest != null: + return {"manifest": manifest} + var local_state := SimulationStateRecord.from_json(json_text) + return {"local_state": local_state} if local_state != null else {} + + +func _is_valid_save_json(json_text: String) -> bool: + return ( + SimulationSaveManifest.from_json(json_text) != null + or SimulationStateRecord.from_json(json_text) != null + ) func _ensure_directory() -> bool: diff --git a/simulation/persistence/SimulationSaveManifest.gd b/simulation/persistence/SimulationSaveManifest.gd new file mode 100644 index 0000000..f830190 --- /dev/null +++ b/simulation/persistence/SimulationSaveManifest.gd @@ -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 diff --git a/simulation/persistence/SimulationSaveManifest.gd.uid b/simulation/persistence/SimulationSaveManifest.gd.uid new file mode 100644 index 0000000..8891db7 --- /dev/null +++ b/simulation/persistence/SimulationSaveManifest.gd.uid @@ -0,0 +1 @@ +uid://cd8g03rq1kh16 diff --git a/simulation/regional/RegionalSimulationFacade.gd b/simulation/regional/RegionalSimulationFacade.gd new file mode 100644 index 0000000..24173b3 --- /dev/null +++ b/simulation/regional/RegionalSimulationFacade.gd @@ -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 diff --git a/simulation/regional/RegionalSimulationFacade.gd.uid b/simulation/regional/RegionalSimulationFacade.gd.uid new file mode 100644 index 0000000..e5c8a22 --- /dev/null +++ b/simulation/regional/RegionalSimulationFacade.gd.uid @@ -0,0 +1 @@ +uid://brlai5b70t6e1 diff --git a/tests/content_catalog_test.gd b/tests/content_catalog_test.gd index e448ea2..8cc43dc 100644 --- a/tests/content_catalog_test.gd +++ b/tests/content_catalog_test.gd @@ -47,8 +47,11 @@ func _test_core_pack() -> void: "Core professions should enumerate by stable ID" ) _check( - _item_ids(catalog.get_items()) == [&"food", &"herb", &"item_claw", &"item_sword", &"wood"], - "Core items should include legacy items and herbs in stable-ID order" + ( + _item_ids(catalog.get_items()) + == [&"food", &"herb", &"item_claw", &"item_sword", &"regional_trade_goods", &"wood"] + ), + "Core items should include local and regional resources in stable-ID order" ) _check( _enemy_ids(catalog.get_enemies()) == [&"enemy_boar", &"enemy_raider", &"enemy_wolf"], @@ -102,14 +105,22 @@ func _test_deterministic_enumeration_and_dependencies() -> void: _check( ( _item_ids(catalog.get_items()) - == [&"amber", &"food", &"herb", &"item_claw", &"item_sword", &"wood"] + == [ + &"amber", + &"food", + &"herb", + &"item_claw", + &"item_sword", + &"regional_trade_goods", + &"wood", + ] ), "Merged item enumeration should be globally stable-ID sorted" ) var returned_items := catalog.get_items() returned_items.clear() _check( - catalog.get_items().size() == 6, + catalog.get_items().size() == 7, "Callers should not be able to mutate the catalog's ordered arrays" ) diff --git a/tests/regional_production_integration_test.gd b/tests/regional_production_integration_test.gd new file mode 100644 index 0000000..f16e13a --- /dev/null +++ b/tests/regional_production_integration_test.gd @@ -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) diff --git a/tests/regional_production_integration_test.gd.uid b/tests/regional_production_integration_test.gd.uid new file mode 100644 index 0000000..d03debb --- /dev/null +++ b/tests/regional_production_integration_test.gd.uid @@ -0,0 +1 @@ +uid://bl8hqwx8a8458 diff --git a/tests/save_slot_persistence_test.gd b/tests/save_slot_persistence_test.gd index e7b96f6..3bc98bc 100644 --- a/tests/save_slot_persistence_test.gd +++ b/tests/save_slot_persistence_test.gd @@ -17,7 +17,13 @@ func _run() -> void: for step in 8: manager.simulate_tick() + _check( + manager.request_regional_jajce_delivery(3.0), + "The quicksave fixture should start one production regional delivery", + ) var saved_checksum: String = manager.get_state_checksum() + var saved_regional_checksum: String = manager.get_regional_state_checksum() + var saved_world_checksum: String = manager.get_world_state_checksum() _check(store.save(manager), "A valid simulation should save atomically") _check(store.has_slot(), "The validated quicksave should be discoverable") @@ -27,11 +33,28 @@ func _run() -> void: manager.get_state_checksum() != saved_checksum, "The scenario should change after the save point" ) + var restore_notifications: Array[StringName] = [] + manager.village_changed.connect( + func(_village: SimVillage) -> void: restore_notifications.append(&"village") + ) + manager.state_restored.connect(func() -> void: restore_notifications.append(&"state")) _check(store.load_into(manager), "A valid quicksave should load") + _check( + restore_notifications == [&"village", &"state"], + "A committed combined restore should publish its two notifications exactly once", + ) _check( manager.get_state_checksum() == saved_checksum, "Loading should restore the exact saved simulation" ) + _check( + manager.get_regional_state_checksum() == saved_regional_checksum, + "Loading should restore the exact mid-route regional authority", + ) + _check( + manager.get_world_state_checksum() == saved_world_checksum, + "Loading should restore both authorities from the same manifest", + ) var final_path := store.get_slot_path() var backup_path := final_path + ".bak" @@ -43,19 +66,187 @@ func _run() -> void: store.load_into(manager), "A preserved previous save should recover an interrupted replacement" ) + _check( + manager.get_world_state_checksum() == saved_world_checksum, + "Backup recovery should restore the complete combined manifest", + ) _check( DirAccess.rename_absolute(backup_path, final_path) == OK, "Test should restore the primary save path" ) - var unchanged_checksum: String = manager.get_state_checksum() - var file := FileAccess.open(final_path, FileAccess.WRITE) - file.store_string('{"schema":"broken"}') - file.close() - _check(not store.load_into(manager), "Invalid save data should be rejected") + var legacy_slot := "legacy_v16" + var legacy_path := store.get_slot_path(legacy_slot) + var legacy_local_checksum: String = manager.get_state_checksum() + var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE) + _check(legacy_file != null, "Test should create a raw legacy local save") + if legacy_file != null: + legacy_file.store_string(manager.serialize_state()) + legacy_file.close() + manager.simulate_tick() _check( - manager.get_state_checksum() == unchanged_checksum, - "A rejected save must not partially mutate the simulation" + store.load_into(manager, legacy_slot), + "Existing raw v16 quicksaves should remain loadable", + ) + _check( + manager.get_state_checksum() == legacy_local_checksum, + "A legacy quicksave should restore its exact local authority", + ) + var legacy_regional := RegionalSimulationFacade.from_dictionary(manager.get_regional_snapshot()) + _check(legacy_regional != null, "Legacy load should bootstrap a valid regional facade") + if legacy_regional != null: + var legacy_service := legacy_regional.get_service() + var legacy_caravan := legacy_service.get_world_state().get_mobile_group( + SimulationIds.REGIONAL_CARAVAN_BLUE + ) + _check( + legacy_regional.get_last_processed_tick() == manager.tick_count, + "Legacy regional bootstrap should align with the saved local tick", + ) + _check( + legacy_regional.get_world_seed() == manager.simulation_seed, + "Legacy regional bootstrap should align with the saved world seed", + ) + _check( + ( + legacy_service.get_scheduler().is_empty() + and legacy_service.get_event_store().is_empty() + ), + "Legacy load must not invent a historical regional journey", + ) + _check( + ( + legacy_caravan != null + and legacy_caravan.is_at_location() + and ( + legacy_caravan.get_current_location_id() + == SimulationIds.REGIONAL_LOCATION_TRAVNIK + ) + ), + "Legacy load should place the idle authored caravan at Travnik", + ) + store.delete_slot(legacy_slot) + + var custom_adapter := ActiveWorldAdapter.new() + _check( + custom_adapter.configure_context_identity( + &"save_test_context", &"save_test_world", &"save_test_location" + ), + "The custom-scope fixture should configure an independent active context", + ) + root.add_child(custom_adapter) + var custom_manager: Node = load("res://simulation/SimulationManager.gd").new() + custom_manager.debug_logs = false + custom_manager.active_world_adapter = custom_adapter + root.add_child(custom_manager) + custom_manager.set_process(false) + var custom_slot := "custom_scope" + var custom_saved_local_checksum: String = custom_manager.get_state_checksum() + var custom_saved_regional_checksum: String = custom_manager.get_regional_state_checksum() + _check( + not custom_manager.uses_combined_save_manifest(), + "A non-Jajce adapter scope should keep the supported local-v16 save boundary", + ) + _check( + custom_manager.can_serialize_local_state_only(), + "A custom scope should allow local-only saving while regional state is reconstructibly idle", + ) + _check( + ( + not custom_manager.request_regional_jajce_delivery(1.0) + and custom_manager.get_regional_state_checksum() == custom_saved_regional_checksum + ), + "A non-Jajce adapter scope must reject Jajce regional mutation without changing authority", + ) + _check( + store.save(custom_manager, custom_slot), + "A supported non-Jajce adapter scope should still save through SaveSlotStore", + ) + var custom_file := FileAccess.open(store.get_slot_path(custom_slot), FileAccess.READ) + _check(custom_file != null, "The custom-scope slot should be readable") + if custom_file != null: + var custom_json := custom_file.get_as_text() + custom_file.close() + _check( + ( + SimulationSaveManifest.from_json(custom_json) == null + and SimulationStateRecord.from_json(custom_json) != null + ), + "A non-Jajce adapter scope should persist the explicit raw local record", + ) + custom_manager.simulate_tick() + _check( + store.load_into(custom_manager, custom_slot), + "The custom-scope raw local record should remain loadable", + ) + _check( + custom_manager.get_state_checksum() == custom_saved_local_checksum, + "Custom-scope loading should restore the exact local authority", + ) + _check( + custom_manager.get_regional_state_checksum() == custom_saved_regional_checksum, + "Custom-scope loading should reconstruct the exact idle regional facade", + ) + var rejected_restore_notifications: Array[StringName] = [] + custom_manager.village_changed.connect( + func(_village: SimVillage) -> void: rejected_restore_notifications.append(&"village") + ) + custom_manager.state_restored.connect( + func() -> void: rejected_restore_notifications.append(&"state") + ) + var custom_before_rejected_local: String = custom_manager.get_state_checksum() + var custom_before_rejected_regional: String = custom_manager.get_regional_state_checksum() + _check( + not custom_manager.restore_save_manifest(manager.create_save_manifest()), + "A Jajce manifest should be rejected by a different active adapter scope", + ) + _check( + rejected_restore_notifications.is_empty(), + "A rejected combined restore and its rollback must publish no success notifications", + ) + _check( + ( + custom_manager.get_state_checksum() == custom_before_rejected_local + and custom_manager.get_regional_state_checksum() == custom_before_rejected_regional + ), + "A rejected combined restore should roll back both custom-scope authorities", + ) + _check( + custom_manager._regional_simulation.depart_jajce_delivery(1.0), + "The fail-closed fixture should create otherwise unreachable live regional state", + ) + var custom_live_regional_checksum: String = custom_manager.get_regional_state_checksum() + _check( + not custom_manager.can_serialize_local_state_only(), + "Live regional state must not be treated as reconstructible local-only authority", + ) + _check( + not store.save(custom_manager, "custom_live"), + "SaveSlotStore must reject a local-only save that would discard live regional state", + ) + _check( + custom_manager.get_regional_state_checksum() == custom_live_regional_checksum, + "Rejecting the lossy custom-scope save must not mutate regional authority", + ) + store.delete_slot(custom_slot) + + var unchanged_world_checksum: String = manager.get_world_state_checksum() + var rejected_manifest: Dictionary = manager.create_save_manifest().to_dictionary() + rejected_manifest["regional_state"]["active_location_id"] = String( + SimulationIds.REGIONAL_LOCATION_TRAVNIK + ) + rejected_manifest["regional_checksum"] = SimulationSaveManifest._checksum_dictionary( + rejected_manifest["regional_state"] + ) + var file := FileAccess.open(final_path, FileAccess.WRITE) + _check(file != null, "Test should open the primary save for corruption") + if file != null: + file.store_string(JSON.stringify(rejected_manifest)) + file.close() + _check(not store.load_into(manager), "Invalid regional manifest data should be rejected") + _check( + manager.get_world_state_checksum() == unchanged_world_checksum, + "A rejected save must not partially mutate either authority", ) _check(not store.save(manager, "../escape"), "Slot names must not escape the save directory") diff --git a/tests/unit/test_regional_simulation_facade.gd b/tests/unit/test_regional_simulation_facade.gd new file mode 100644 index 0000000..8863c75 --- /dev/null +++ b/tests/unit/test_regional_simulation_facade.gd @@ -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))) diff --git a/tests/unit/test_regional_simulation_facade.gd.uid b/tests/unit/test_regional_simulation_facade.gd.uid new file mode 100644 index 0000000..75ba7c7 --- /dev/null +++ b/tests/unit/test_regional_simulation_facade.gd.uid @@ -0,0 +1 @@ +uid://0flo0tck1bwn diff --git a/tests/unit/test_simulation_save_manifest.gd b/tests/unit/test_simulation_save_manifest.gd new file mode 100644 index 0000000..4412fba --- /dev/null +++ b/tests/unit/test_simulation_save_manifest.gd @@ -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) diff --git a/tests/unit/test_simulation_save_manifest.gd.uid b/tests/unit/test_simulation_save_manifest.gd.uid new file mode 100644 index 0000000..780840b --- /dev/null +++ b/tests/unit/test_simulation_save_manifest.gd.uid @@ -0,0 +1 @@ +uid://cradryxj45n86