diff --git a/Makefile b/Makefile index 76009c4..6a9a092 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,8 @@ test-s2: $(GODOT) --headless --path . --script res://tests/s2/test_economy_service.gd $(GODOT) --headless --path . --script res://tests/s2/test_ledger_backed_resources.gd $(GODOT) --headless --path . --script res://tests/s2/test_mana_network_service.gd + $(GODOT) --headless --path . --script res://tests/s2/test_mana_reserve_cycle.gd + $(GODOT) --headless --path . --script res://tests/s2/test_runtime_mana_reserve.gd $(GODOT) --headless --path . --script res://tests/s2/test_runtime_operations_cycle.gd test-s3: diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 64e1d61..6076a80 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -29,6 +29,7 @@ const AutoPurchasePlansType := preload("res://scripts/supply/supply_auto_purchas const CLEANING_RUNTIME_SNAPSHOT_VERSION := 12 const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11 const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd") +const ManaReserveCycle = preload("res://scripts/mana/mana_reserve_cycle.gd") const CleaningBridge = preload("res://scripts/environment/facility_cleaning_bridge.gd") const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd") const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10 @@ -108,6 +109,7 @@ var facility_cleanliness := FacilityCleanliness.new() var cleaning_policy := CleaningPolicy.new() var cleaning_bridge := CleaningBridge.new() var facility_upkeep := FacilityUpkeepAccrual.new() +var mana_reserve := ManaReserveCycle.new() var upkeep_settlement := FacilityUpkeepSettlement.new() var economy_service: EconomyService var construction_resource_state: LedgerBackedResourceState @@ -539,6 +541,7 @@ func create_snapshot() -> Dictionary: &"path": path_service.create_snapshot(), &"movement_credits": _serialize_movement_credits(), &"mana": mana_network_service.create_snapshot(), + &"mana_reserve": mana_reserve.create_snapshot(), &"economy": economy_service.create_snapshot(), &"facility_upkeep": facility_upkeep.create_snapshot(), &"upkeep_settlement": upkeep_settlement.create_snapshot(), @@ -676,6 +679,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo runtime_current_keys.append(&"auto_purchase_plans") if runtime_data.has(&"special_peeping_rooms"): runtime_current_keys.append(&"special_peeping_rooms") + if runtime_data.has(&"mana_reserve"): + runtime_current_keys.append(&"mana_reserve") if not _dictionary_has_exact_canonical_keys(runtime_data, runtime_current_keys) \ or typeof(runtime_data.get(&"runtime_schema_version", null)) != TYPE_INT \ or typeof(runtime_data.get(&"world_seed", null)) != TYPE_INT \ @@ -857,6 +862,23 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo var restored_mana := ManaNetworkService.new() if not restored_mana.restore_snapshot(runtime_data[&"mana"]): return false + # A06: saves without the finite reserve keep an explicit stored amount + # (0 stays stopped) only within capacity; nothing is silently truncated. + var restored_mana_reserve := ManaReserveCycle.new() + var saved_game_resources: Variant = snapshot[&"game_state"].get(&"resources", null) + if not saved_game_resources is Dictionary \ + or not ManaReserveCycle.amount_is_valid(saved_game_resources.get(&"mana", saved_game_resources.get("mana", null))): + return false + if runtime_data.has(&"mana_reserve"): + if not restored_mana_reserve.restore_snapshot(runtime_data[&"mana_reserve"]) \ + or restored_mana_reserve.get_minute() > restored_minute: + return false + var last_reserve_flow := restored_mana_reserve.get_last_flow() + if not last_reserve_flow.is_empty() \ + and int(saved_game_resources.get(&"mana", saved_game_resources.get("mana", 0))) > int(last_reserve_flow[&"after"]): + return false + elif not restored_mana_reserve.start_at(restored_minute): + return false var restored_economy := EconomyService.new() if not restored_economy.restore_snapshot(runtime_data[&"economy"]): return false @@ -1325,6 +1347,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo _movement_credits = (restored_movement_credits as Dictionary).duplicate(true) _connect_path_performance_signal() mana_network_service = restored_mana + mana_reserve = restored_mana_reserve weather = restored_weather facility_cleanliness = restored_cleanliness facility_upkeep = restored_upkeep @@ -2054,6 +2077,7 @@ func _build_operations_external_state() -> Dictionary: &"personal_action_points": _personal_action_points, # The remaining aggregates are reachable through Event actions or signals. &"mana": mana_network_service.create_snapshot(), + &"mana_reserve": mana_reserve.create_snapshot(), &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), &"logistics_requests": _serialize_logistics_requests(), &"defense": defense_service.create_snapshot(), @@ -2091,7 +2115,7 @@ func _restore_operations_external_state(token: Variant) -> bool: &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", &"movement_credits", &"domain_events", &"id_generator", &"rng_streams", - &"progression", &"hired_staff_candidate_ids", &"mana", + &"progression", &"hired_staff_candidate_ids", &"mana", &"mana_reserve", &"logistics_dispatcher", &"defense", &"construction", ] for key: StringName in required_dictionary_keys: @@ -2152,6 +2176,7 @@ func _restore_operations_external_state(token: Variant) -> bool: var ids_ok := id_generator.restore_snapshot(saved[&"id_generator"]) var progression_ok := progression_service.restore_snapshot(saved[&"progression"]) var mana_ok := mana_network_service.restore_snapshot(saved[&"mana"]) + mana_ok = mana_reserve.restore_snapshot(saved[&"mana_reserve"]) and mana_ok var dispatcher_ok := logistics_dispatcher.restore_snapshot(saved[&"logistics_dispatcher"]) var defense_ok := defense_service.restore_snapshot(saved[&"defense"]) var construction_ok := construction_service.restore_snapshot(saved[&"construction"]) @@ -2258,6 +2283,7 @@ func _operations_external_state_matches(saved: Dictionary) -> bool: or not _strict_variants_equal(id_generator.create_snapshot(), saved[&"id_generator"]) \ or not _strict_variants_equal(progression_service.create_snapshot(), saved[&"progression"]) \ or not _strict_variants_equal(mana_network_service.create_snapshot(), saved[&"mana"]) \ + or not _strict_variants_equal(mana_reserve.create_snapshot(), saved[&"mana_reserve"]) \ or not _strict_variants_equal(logistics_dispatcher.create_snapshot(), saved[&"logistics_dispatcher"]) \ or not _strict_variants_equal(_serialize_logistics_requests(), saved[&"logistics_requests"]) \ or not _strict_variants_equal(defense_service.create_snapshot(), saved[&"defense"]) \ @@ -5226,6 +5252,8 @@ func _initialize_economy() -> void: upkeep_settlement = FacilityUpkeepSettlement.new() FacilityUpkeepSettlement.prepare_accounts(economy_service) facility_upkeep.start_at(_clock.absolute_minute) + mana_reserve = ManaReserveCycle.new() + mana_reserve.start_at(_clock.absolute_minute) construction_resource_state = _create_resource_adapter( economy_service, CONSTRUCTION_EXPENSE_ACCOUNT, @@ -10729,6 +10757,21 @@ func _advance_mana(absolute_minute: int) -> void: infrastructure_preflight.get(&"details", {}) ) return + # A06: generation and upkeep demand settle before local distribution and the + # whole-tower gate, so the network and projection see the committed reserve. + var reserve_flow := mana_reserve.advance_to( + absolute_minute, int(_state.get_resource(&"mana")), mana_network_service.get_total_consumer_demand() + ) + if not bool(reserve_flow.get(&"ok", false)): + _latch_infrastructure_failure( + StringName(reserve_flow.get(&"code", &"MANA_RESERVE_FAILED")), + absolute_minute, + "The finite Mana reserve could not settle this boundary.", + {&"mana": int(_state.get_resource(&"mana"))} + ) + return + if bool(reserve_flow.get(&"processed", false)): + _state.set_resource(&"mana", int(reserve_flow[&"after"])) var report := mana_network_service.advance_one_minute(absolute_minute) # Finite reserve is an additional gate, even between network evaluations and # for zero-demand facilities. The network remains the local supply authority. diff --git a/docs/plans/original-completion-2026-10-02/decisions.json b/docs/plans/original-completion-2026-10-02/decisions.json index 36fef7c..2b60de0 100644 --- a/docs/plans/original-completion-2026-10-02/decisions.json +++ b/docs/plans/original-completion-2026-10-02/decisions.json @@ -33,6 +33,14 @@ "decision": "2×1・裏層。建築350G、資材4、工期60分、清掃員最低1人/上限2人、顧客0人・利用料なし、維持費2G/日。清潔0〜100・初期100、75以下で予約、現地8分で25回復。定義済み汚れ量を使い未定義値を補完しない。", "origin": "user_reply", "reference": "../original-completion-2026-09-29/execution/CLEANING_APPROVED_FOUNDATION.md" + }, + { + "id": "A06", + "title": "マナ有限残量契約(MP-01-r02推薦案)", + "decision": "初期100・容量300・世界樹生成20/ゲーム内10分。10分境界ごとにB+G+F−Dを計算し、需要を先に支払い、超過は明示破棄、0で全館停止。需要は登録済み全consumerのmaintenance_mana合計(閉店・停止中も計上)。炉の投入は自然生成と需要支払後のbase_after=min(C,max(0,B+G−D))で空き100以上のとき受理。現Runtimeのphase順序を保持しMANA内で処理。旧セーブの残量は0〜300のみ保持し、超過・不正値は拒否。炉の人物適格性・収率の実接続は別承認。", + "origin": "user_reply_in_conversation", + "approved_at_jst": "2026-10-19", + "reference": "execution/mana-proposal/PROPOSAL.md" } ], "pending": [ diff --git a/docs/plans/original-completion-2026-10-02/execution/p06-01-a06/IMPLEMENTATION.md b/docs/plans/original-completion-2026-10-02/execution/p06-01-a06/IMPLEMENTATION.md new file mode 100644 index 0000000..36147c8 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p06-01-a06/IMPLEMENTATION.md @@ -0,0 +1,20 @@ +# P06-01:A06 マナ有限残量契約の実装(2026-10-19) + +ユーザーが MP-01-r02 推薦案を採用しました(decisions.json A06)。内容は初期100、容量300、世界樹の生成20/ゲーム内10分です。炉は、自然生成と需要の支払いを済ませた後に判定します。 + +## 製品への反映 + +- `scripts/mana/mana_reserve_cycle.gd`:承認値と境界処理です。10分境界ごとに `available=B+G+F`、`consumed=min(available,D)`、`after=min(C,available−consumed)` を計算します。容量を超えた分は overflow として記録し、破棄します。同じ境界は二度処理しません。境界を飛ばした場合は、各境界を順番に処理します。保存データは検証が通ってから反映し、不正なら何も変えません。炉の受理判定は `furnace_offer_accepted`(base_after 後の空きが100以上)です。 +- `scripts/mana/mana_network_service.gd`:`get_total_consumer_demand()` を追加しました。登録済みの全 consumer の需要を合計します。無効化・停止中の consumer も含みます。 +- `autoload/simulation_runtime.gd`:MANA phase の最初で残量を確定してから、局所配電と全館ゲートを評価します。phase の順序は変えていません。新しいセーブは `mana_reserve` を持ちます。Operations の巻き戻し対象にも含めました。 +- 旧セーブの移行:`mana_reserve` が無いセーブは、残量が0〜300の整数であれば、その値を保持して開始します。明示的な0は停止のまま保持します。300超・負値・非整数は拒否し、現在の状態を保持します。 + +## 試験 + +- 新規:`tests/s2/test_mana_reserve_cycle.gd`(提案書の比較表の推薦列の値、炉判定の3例、改ざん拒否)、`tests/s2/test_runtime_mana_reserve.gd`(Runtimeでの境界処理、保存往復、旧0の移行、容量超過の拒否、枯渇と復帰)。 +- 既存の停電試験は「マナを0にすれば停電が続く」前提でした。A06では世界樹が次の境界で復帰させるため、停電を継続させる試験だけ `tests/helpers/mana_outage.gd` に置き換えました。これは配電を受けない無効 consumer で需要を足す方法です。 + +## 未了 + +- 炉の人物適格性・収率の実接続、未定義13施設の需要、D05 の詳細は別承認です。 +- 全館の実プレイ、性能、Windows での受入は未実施です。原88の正式完了数は変わりません。 diff --git a/docs/plans/original-completion-2026-10-02/plan_validation.json b/docs/plans/original-completion-2026-10-02/plan_validation.json index ba8525a..0af5038 100644 --- a/docs/plans/original-completion-2026-10-02/plan_validation.json +++ b/docs/plans/original-completion-2026-10-02/plan_validation.json @@ -84,7 +84,7 @@ "passed": true }, { - "name": "all5_confirmed_decisions", + "name": "all6_confirmed_decisions", "passed": true }, { @@ -238,7 +238,7 @@ "build_atomic_requirements.py": "6d6ea9e4216372dbd0f9fed3f885a4481b21a3f539a26f64cf98a6830af984c5", "build_facility_value_contracts.py": "35027b23ce75ce9e4e0cdfc5132fd2eb771ba9f598d97750d7ecf9ecb257f769", "current_evidence_links.json": "db1837bcc8497201134297e312f4b266a297102285307ec3075d4e23632e26c2", - "decisions.json": "ad0673051b0fa601e525f05128e0b11e2dddd9d4bae6a3e0170bbf47da35efe9", + "decisions.json": "927065d9f840e68667d10998d26eb5d04f5591d9f24082106d082e51d5af5c96", "facility_value_contracts.json": "447388ba03a29bfac25f2bcd068665aedbb07ba7641ce86f2db93d44f4c9f326", "facility_value_contracts_validation.json": "bf490a9ca585c48fc318e76c4a069ef244827d828d46e6415399f7710590dd16", "performance_audit.json": "0b630b15a392c3e0366f1362bfe4ddacb968f915f54549f82e4d6fa315384762", @@ -251,7 +251,7 @@ "source_atomic_coverage.json": "2ab7b9e6a2536ef248d4f7ccd695bf75a082b47018aca7a5a1b6b706fe1907bc", "validate_atomic_requirements.py": "5b2d0b7d72cb124830fbb0a12d1434e6db698148cc5263d33fd5bd7c2953a1e0", "validate_facility_value_contracts.py": "91640e2a76cd6cc9186f7b85c0bf379b24b6cac0d48962815235ee628e7ae43e", - "validate_plan.py": "23678227d9b3c556f1bd903f4b5b3147c4a82ce5085e10055481026803451b80", + "validate_plan.py": "e31316aff6c814d1e1660a97b91cc0c0016d2b722c4d4092a71859a85bd0e65d", "validator_update_checks.json": "f997d0f3de2cea29550840b1d5cb23647d64f81329de46758551e2b6c8fecc27", "work_packages.json": "1913634b33ca25bec6d5a0a6a4d3d9258b7fd4c9c79c9ab426f21ac2b11c6e81" } diff --git a/docs/plans/original-completion-2026-10-02/validate_plan.py b/docs/plans/original-completion-2026-10-02/validate_plan.py index 5fa5eea..4090fb0 100644 --- a/docs/plans/original-completion-2026-10-02/validate_plan.py +++ b/docs/plans/original-completion-2026-10-02/validate_plan.py @@ -172,7 +172,7 @@ def main(): pending = {d["id"] for d in decisions["pending"]} check("all10_pending_decision_groups", pending == {f"D{i:02d}" for i in range(1, 11)}) check("known_decision_references", all(set(p["decisions"]) <= pending for p in packages)) - check("all5_confirmed_decisions", {d["id"] for d in decisions["confirmed"]} == {f"A{i:02d}" for i in range(1, 6)}) + check("all6_confirmed_decisions", {d["id"] for d in decisions["confirmed"]} == {f"A{i:02d}" for i in range(1, 7)}) check("confirmed_decision_references_exist", all((PLAN / d["reference"]).is_file() for d in decisions["confirmed"] if "reference" in d)) state_counts = dict(collections.Counter(p["state"] for p in packages)) check("valid_package_states", all(p["state"] in states for p in packages)) diff --git a/scripts/mana/mana_network_service.gd b/scripts/mana/mana_network_service.gd index e256c25..8b789fc 100644 --- a/scripts/mana/mana_network_service.gd +++ b/scripts/mana/mana_network_service.gd @@ -231,6 +231,15 @@ func get_storage_current(node_id: StringName) -> int: return int(_nodes[node_id][&"current"]) +## Upkeep demand of every registered consumer, including disabled ones. +func get_total_consumer_demand() -> int: + var total := 0 + for node_id: StringName in _nodes: + if int(_nodes[node_id][&"kind"]) == NodeKind.CONSUMER: + total += int(_nodes[node_id][&"demand"]) + return total + + func get_last_report() -> Dictionary: return _last_report.duplicate(true) diff --git a/scripts/mana/mana_reserve_cycle.gd b/scripts/mana/mana_reserve_cycle.gd new file mode 100644 index 0000000..f70fcfe --- /dev/null +++ b/scripts/mana/mana_reserve_cycle.gd @@ -0,0 +1,132 @@ +extends RefCounted + +## Approved finite Mana reserve contract A06 (MP-01-r02 recommended plan). +## GameState keeps the stored amount so existing spending stays authoritative; +## this owns the processed 10-minute boundary and the last flow, so a boundary +## is consumed exactly once and replays after rollback reproduce the same flow. +const VERSION := 1 +const POLICY_ID := &"MP-01-r02" +const INITIAL_MANA := 100 +const CAPACITY := 300 +const WORLD_TREE_GENERATION := 20 +const INTERVAL_MINUTES := 10 +const FURNACE_YIELD := 100 +const LIMIT := 1000000000 +const FLOW_KEYS: Array[String] = [ + "from_minute", "to_minute", "before", "generation", "furnace", "demand", + "consumed", "shortage", "overflow", "after", +] + +var _minute := -1 +var _last_flow: Dictionary = {} + +func start_at(minute: Variant) -> bool: + if _minute >= 0 or typeof(minute) != TYPE_INT or minute < 0: + return false + _minute = minute - minute % INTERVAL_MINUTES + return true + +func get_minute() -> int: + return _minute + +func get_last_flow() -> Dictionary: + return _last_flow.duplicate(true) + +## Processes every boundary in (last boundary, minute]. Boundaries already +## processed are no-ops, so per-minute callers and same-minute retries are safe. +func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary: + if _minute < 0: + return _failure(&"NOT_STARTED") + if typeof(minute) != TYPE_INT or not _amount(before) or before > CAPACITY \ + or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute: + return _failure(&"INVALID_FLOW") + var boundary: int = minute - minute % INTERVAL_MINUTES + if boundary <= _minute: + return {&"ok": true, &"processed": false, &"after": before} + var amount: int = before + var flow := {} + var count := 0 + for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES): + flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute) + amount = flow[&"after"] + count += 1 + _minute = boundary + _last_flow = flow + return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)} + +## Furnace offers are judged after natural generation and upkeep demand are +## paid: base_after = min(C, max(0, B + G - D)); accept only with 100 free. +static func furnace_offer_accepted(before: int, demand: int) -> bool: + var base_after := mini(CAPACITY, maxi(0, before + WORLD_TREE_GENERATION - demand)) + return CAPACITY - base_after >= FURNACE_YIELD + +static func plan(before: int, demand: int, furnace: int, start: int, end: int) -> Dictionary: + var available := before + WORLD_TREE_GENERATION + furnace + var consumed := mini(available, demand) + var remaining := available - consumed + return {&"from_minute": start, &"to_minute": end, &"before": before, + &"generation": WORLD_TREE_GENERATION, &"furnace": furnace, &"demand": demand, + &"consumed": consumed, &"shortage": demand - consumed, + &"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)} + +static func amount_is_valid(value: Variant) -> bool: + return _amount(value) and value <= CAPACITY + +func create_snapshot() -> Dictionary: + return {&"version": VERSION, &"policy_id": POLICY_ID, &"capacity": CAPACITY, + &"generation": WORLD_TREE_GENERATION, &"interval_minutes": INTERVAL_MINUTES, + &"minute": _minute, &"last_flow": _last_flow.duplicate(true)} + +## Validates fully before committing; a rejected snapshot leaves state intact. +func restore_snapshot(source: Variant) -> bool: + if not source is Dictionary or not _keys(source, ["version", "policy_id", "capacity", "generation", "interval_minutes", "minute", "last_flow"]): + return false + if typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \ + or typeof(source["policy_id"]) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(source["policy_id"]) != POLICY_ID \ + or typeof(source["capacity"]) != TYPE_INT or source["capacity"] != CAPACITY \ + or typeof(source["generation"]) != TYPE_INT or source["generation"] != WORLD_TREE_GENERATION \ + or typeof(source["interval_minutes"]) != TYPE_INT or source["interval_minutes"] != INTERVAL_MINUTES \ + or not _amount(source["minute"]) or source["minute"] % INTERVAL_MINUTES != 0 \ + or not source["last_flow"] is Dictionary: + return false + var flow: Dictionary = source["last_flow"] + if not flow.is_empty(): + if not _keys(flow, FLOW_KEYS): + return false + for key: Variant in flow: + if not _amount(flow[key]): + return false + if flow["to_minute"] != source["minute"] or flow["before"] > CAPACITY \ + or flow["generation"] != WORLD_TREE_GENERATION \ + or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]: + return false + var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"]) + if not _same_flow(expected, flow): + return false + _minute = source["minute"] + var normalized := {} + for key: Variant in flow: + normalized[StringName(key)] = flow[key] + _last_flow = normalized + return true + +static func _same_flow(expected: Dictionary, actual: Dictionary) -> bool: + for key: String in FLOW_KEYS: + if expected[StringName(key)] != actual[key]: + return false + return true + +static func _amount(value: Variant) -> bool: + return typeof(value) == TYPE_INT and value >= 0 and value <= LIMIT + +static func _keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + for key: Variant in source: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: + return false + return true + +static func _failure(code: StringName) -> Dictionary: + return {&"ok": false, &"code": code} diff --git a/scripts/mana/mana_reserve_cycle.gd.uid b/scripts/mana/mana_reserve_cycle.gd.uid new file mode 100644 index 0000000..6918a97 --- /dev/null +++ b/scripts/mana/mana_reserve_cycle.gd.uid @@ -0,0 +1 @@ +uid://dhi1bipyd67tx diff --git a/tests/helpers/mana_outage.gd b/tests/helpers/mana_outage.gd new file mode 100644 index 0000000..155d318 --- /dev/null +++ b/tests/helpers/mana_outage.gd @@ -0,0 +1,18 @@ +extends RefCounted + +## Test-only sustained reserve outage under the A06 finite Mana contract. +## Zeroing the reserve alone recovers at the next 10-minute boundary because the +## world tree keeps generating. A disabled consumer adds upkeep demand that the +## reserve must pay but takes no local network allocation, so the tower stays +## at 0 until release() without changing any other consumer's supply. +const SINK_ID := &"test.mana_outage_sink" +const SINK_DEMAND := 1000 + +static func hold(runtime: Variant, state: Variant) -> void: + if runtime.mana_network_service.get_node(SINK_ID).is_empty(): + runtime.mana_network_service.register_consumer(SINK_ID, SINK_DEMAND, ManaNetworkService.ConsumerPriority.NORMAL, false) + state.set_resource(&"mana", 0) + +static func release(runtime: Variant, state: Variant, amount := 100) -> void: + runtime.mana_network_service.unregister_node(SINK_ID) + state.set_resource(&"mana", amount) diff --git a/tests/mvp/test_premium_room_guest_flow.gd b/tests/mvp/test_premium_room_guest_flow.gd index e27a00c..5da8433 100644 --- a/tests/mvp/test_premium_room_guest_flow.gd +++ b/tests/mvp/test_premium_room_guest_flow.gd @@ -1,4 +1,5 @@ extends SceneTree +const ManaOutage = preload("res://tests/helpers/mana_outage.gd") var checks := 0 var failures := 0 var runtime: Variant @@ -59,7 +60,7 @@ func run() -> void: var mid: Dictionary=runtime.create_snapshot() expect(runtime.restore_snapshot(mid), "real guest and in-progress premium service restore together") var elapsed: int=runtime.facility_service.get_service(service_id).elapsed_minutes - state.set_resource(&"mana",0) + ManaOutage.hold(runtime, state) clock.advance_minutes(15) var paused: ServiceInstance=runtime.facility_service.get_service(service_id) expect(paused.state==ServiceInstance.State.PAUSED and paused.elapsed_minutes==elapsed, "zero mana pauses actual guest lodging without consuming service time") @@ -70,7 +71,7 @@ func run() -> void: var saved := SaveService.load_snapshot(disk_path) expect(saved[&"ok"] and runtime.restore_snapshot(saved[&"snapshot"]), "restore real guest, paused service and empty mana from disk") SaveService.delete_save(disk_path) - state.set_resource(&"mana",100) + ManaOutage.release(runtime, state) for minute in 370: clock.advance_one_minute() diff --git a/tests/s2/test_mana_reserve_cycle.gd b/tests/s2/test_mana_reserve_cycle.gd new file mode 100644 index 0000000..11fc9ca --- /dev/null +++ b/tests/s2/test_mana_reserve_cycle.gd @@ -0,0 +1,59 @@ +extends SceneTree +const Cycle = preload("res://scripts/mana/mana_reserve_cycle.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) + +func run(before: int, demand: int, minutes: int) -> int: + var cycle = Cycle.new() + cycle.start_at(360) + var amount := before + for minute: int in range(361, 360 + minutes + 1): + var result: Dictionary = cycle.advance_to(minute, amount, demand) + amount = int(result[&"after"]) + return amount + +func _initialize() -> void: + expect(Cycle.INITIAL_MANA == 100 and Cycle.CAPACITY == 300 and Cycle.WORLD_TREE_GENERATION == 20, "approved A06 values") + # Proposal comparison table (recommended column), F=0, start 360. + expect(run(100, 2, 120) == 300, "low load fills to capacity") + expect(run(100, 16, 120) == 148, "normal load 100+12*(20-16)") + expect(run(100, 63, 120) == 0, "overload depletes") + expect(run(0, 16, 10) == 4, "zero reserve recovers on next boundary") + expect(run(300, 2, 10) == 300, "full reserve keeps capacity") + var cycle = Cycle.new() + expect(not cycle.advance_to(370, 100, 16)[&"ok"], "unstarted cycle rejected") + expect(cycle.start_at(365) and cycle.get_minute() == 360, "start floors to boundary") + expect(not cycle.advance_to(369, 100, 16)[&"processed"], "non-boundary minute is a no-op") + var flow: Dictionary = cycle.advance_to(370, 100, 63)[&"flow"] + expect(flow[&"after"] == 57 and flow[&"consumed"] == 63 and flow[&"overflow"] == 0, "demand paid from generation and reserve") + var saved: Dictionary = cycle.create_snapshot() + expect(not cycle.advance_to(370, 57, 63)[&"processed"] and cycle.create_snapshot() == saved, "same boundary never consumed twice") + expect(not cycle.advance_to(360, 57, 63)[&"ok"], "time reversal rejected") + expect(not cycle.advance_to(380, 301, 0)[&"ok"] and cycle.create_snapshot() == saved, "over-capacity reserve rejected without change") + var skipped: Dictionary = cycle.advance_to(400, 57, 63) + expect(skipped[&"boundaries"] == 3 and skipped[&"after"] == 0 and skipped[&"flow"][&"shortage"] == 43, "skipped boundaries settle in order") + var restored = Cycle.new() + expect(restored.restore_snapshot(cycle.create_snapshot()) and restored.create_snapshot() == cycle.create_snapshot(), "snapshot round trip") + var corrupt: Dictionary = cycle.create_snapshot() + corrupt[&"last_flow"][&"after"] += 1 + var untouched = Cycle.new() + expect(not untouched.restore_snapshot(corrupt) and untouched.get_minute() == -1, "tampered flow rejected atomically") + corrupt = cycle.create_snapshot() + corrupt[&"capacity"] = 600 + expect(not untouched.restore_snapshot(corrupt), "foreign policy capacity rejected") + corrupt = cycle.create_snapshot() + corrupt[&"minute"] = 405 + expect(not untouched.restore_snapshot(corrupt), "off-boundary minute rejected") + # Furnace is judged after natural generation and demand payment. + expect(not Cycle.furnace_offer_accepted(200, 16), "B=200 base_after 204 free 96 rejected") + expect(Cycle.furnace_offer_accepted(196, 16), "B=196 base_after 200 free 100 accepted") + expect(not Cycle.furnace_offer_accepted(300, 16), "B=300 full rejected") + expect(Cycle.plan(196, 16, 100, 360, 370)[&"after"] == 300, "accepted furnace offer fills exactly") + expect(Cycle.amount_is_valid(0) and Cycle.amount_is_valid(300) and not Cycle.amount_is_valid(301) and not Cycle.amount_is_valid(-1) and not Cycle.amount_is_valid(1.5), "legacy amount bounds") + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/s2/test_mana_reserve_cycle.gd.uid b/tests/s2/test_mana_reserve_cycle.gd.uid new file mode 100644 index 0000000..71aba22 --- /dev/null +++ b/tests/s2/test_mana_reserve_cycle.gd.uid @@ -0,0 +1 @@ +uid://de3w8hfsk83nt diff --git a/tests/s2/test_runtime_mana_reserve.gd b/tests/s2/test_runtime_mana_reserve.gd new file mode 100644 index 0000000..570b4cc --- /dev/null +++ b/tests/s2/test_runtime_mana_reserve.gd @@ -0,0 +1,109 @@ +extends SceneTree + +const Cycle = preload("res://scripts/mana/mana_reserve_cycle.gd") +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S2: runtime finite Mana reserve (A06)") + _reset_runtime() + _test_generation_and_demand() + _test_save_round_trip_and_migration() + _test_depletion_stops_tower() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime() -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, 3303) + _clock.set_paused(true) + _expect(report[&"valid"], "runtime initializes") + _expect(_state.get_resource(&"mana") == Cycle.INITIAL_MANA, "new game starts with approved 100 Mana") + _expect(_runtime.mana_reserve.get_minute() == _clock.absolute_minute, "reserve starts on the opening boundary") + + +func _cleanup() -> void: + for node: Variant in [_runtime, _clock, _state]: + if node != null: + node.free() + _runtime = null + _clock = null + _state = null + + +func _test_generation_and_demand() -> void: + _clock.advance_minutes(10) + var demand: int = _runtime.mana_network_service.get_total_consumer_demand() + var flow: Dictionary = _runtime.mana_reserve.get_last_flow() + _expect(flow[&"generation"] == 20 and flow[&"demand"] == demand, "boundary uses world tree 20 and registered demand") + _expect(_state.get_resource(&"mana") == mini(Cycle.CAPACITY, 100 + 20 - demand), "one boundary settles exactly once") + _clock.advance_minutes(9) + var mid: int = _state.get_resource(&"mana") + _expect(mid == int(flow[&"after"]), "no settlement between boundaries") + _clock.advance_minutes(200) + _expect(_state.get_resource(&"mana") <= Cycle.CAPACITY, "reserve never exceeds capacity") + + +func _test_save_round_trip_and_migration() -> void: + var snapshot: Dictionary = _runtime.create_snapshot() + var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] + _expect(runtime_data.has(&"mana_reserve"), "new saves carry the reserve contract") + _expect(_runtime.restore_snapshot(snapshot.duplicate(true)), "current save restores") + _expect(_runtime.create_snapshot() == snapshot, "restored reserve state is identical") + + var legacy: Dictionary = snapshot.duplicate(true) + legacy[&"systems"][&"runtime"].erase(&"mana_reserve") + legacy[&"game_state"][&"resources"][&"mana"] = 0 + _expect(_runtime.restore_snapshot(legacy), "legacy save with explicit 0 migrates") + _expect(_state.get_resource(&"mana") == 0 and _runtime.mana_reserve.get_last_flow().is_empty(), "explicit 0 stays 0 with fresh reserve") + + var over: Dictionary = snapshot.duplicate(true) + over[&"systems"][&"runtime"].erase(&"mana_reserve") + over[&"game_state"][&"resources"][&"mana"] = Cycle.CAPACITY + 1 + var before: Dictionary = _runtime.create_snapshot() + _expect(not _runtime.restore_snapshot(over), "legacy amount over capacity is rejected") + _expect(_runtime.create_snapshot() == before, "rejected migration keeps live state") + + var tampered: Dictionary = snapshot.duplicate(true) + tampered[&"systems"][&"runtime"][&"mana_reserve"][&"last_flow"][&"after"] += 1 + _expect(not _runtime.restore_snapshot(tampered), "tampered reserve flow is rejected") + _expect(_runtime.restore_snapshot(snapshot.duplicate(true)), "original save still restores") + + +func _test_depletion_stops_tower() -> void: + _state.set_resource(&"mana", 0) + var demand: int = _runtime.mana_network_service.get_total_consumer_demand() + _runtime.mana_network_service.register_consumer(&"test.mana_sink", 40) + _clock.advance_minutes(10 - (_clock.absolute_minute % 10)) + _expect(_state.get_resource(&"mana") == 0, "demand above generation keeps the reserve at 0") + _expect(int(_runtime.mana_reserve.get_last_flow()[&"shortage"]) == demand + 40 - 20, "shortage is recorded") + _runtime.mana_network_service.unregister_node(&"test.mana_sink") + _clock.advance_minutes(10) + _expect(_state.get_resource(&"mana") == maxi(0, 20 - demand), "world tree recovers the reserve at the next boundary") + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + else: + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/staff/test_rescue_elevator.gd b/tests/staff/test_rescue_elevator.gd index c7295d4..c0a8d4b 100644 --- a/tests/staff/test_rescue_elevator.gd +++ b/tests/staff/test_rescue_elevator.gd @@ -1,4 +1,5 @@ extends SceneTree +const ManaOutage = preload("res://tests/helpers/mana_outage.gd") var checks := 0 var failures := 0 var runtime: Variant @@ -50,7 +51,7 @@ func run() -> void: expect(patient.current_cell == medic.current_cell and patient.health == 0 and runtime.elevators.get_report()[&"trips"].is_empty(), "emergency unloads both at the same floor without healing") expect(runtime.restore_snapshot(runtime.create_snapshot()), "emergency pair release remains saveable") expect(runtime.restore_snapshot(checkpoint), "restore boarded rescue pair for tower-wide depletion") - state.set_resource(&"mana", 0) + ManaOutage.hold(runtime, state) clock.advance_minutes(1) patient = runtime.staff_service.get_staff(&"patient"); medic = runtime.staff_service.get_staff(&"medic") expect(patient.current_cell == medic.current_cell and patient.health == 0 and runtime.elevators.get_report()[&"trips"].is_empty(), "reserve depletion lands both rescue passengers together without healing") @@ -60,7 +61,7 @@ func run() -> void: expect(runtime.staff_service.get_staff(&"patient").health == 0 and runtime.staff_service.get_staff(&"patient").current_cell == landing, "stopped clinic cannot heal or teleport the landed patient") var outage: Dictionary = runtime.create_snapshot() expect(runtime.staff_service.validate_invariants()[&"ok"] and runtime.restore_snapshot(outage) and runtime.create_snapshot() == outage, "depleted rescue ownership survives exact complete save restoration") - state.set_resource(&"mana", 100) + ManaOutage.release(runtime, state) clock.advance_minutes(100) expect(runtime.staff_service.get_staff(&"patient").health == 100, "restored power resumes rescue and treatment for the same patient") expect(runtime.staff_service.validate_invariants()[&"ok"] and runtime.restore_snapshot(runtime.create_snapshot()), "recovered rescue has no orphan ownership and remains saveable") diff --git a/tests/staff/test_staff_dormitory.gd b/tests/staff/test_staff_dormitory.gd index c67567d..2c42560 100644 --- a/tests/staff/test_staff_dormitory.gd +++ b/tests/staff/test_staff_dormitory.gd @@ -1,4 +1,5 @@ extends SceneTree +const ManaOutage = preload("res://tests/helpers/mana_outage.gd") var checks := 0 var failures := 0 var runtime: Variant @@ -40,11 +41,11 @@ func run() -> void: expect(runtime.create_snapshot()==arrived,"staff movement and recovery replay exactly") worker=runtime.staff_service.get_staff(&"dorm_sleeper") var health:=worker.health - state.set_resource(&"mana",0);clock.advance_minutes(30) + ManaOutage.hold(runtime, state);clock.advance_minutes(30) expect(worker.health==health and not runtime.get_staff_rest_report(worker.staff_id).get(&"resting",false),"unpowered dormitory stops recovery") expect(runtime.restore_snapshot(runtime.create_snapshot()),"stopped dormitory and staff position restore") worker=runtime.staff_service.get_staff(&"dorm_sleeper") - state.set_resource(&"mana",100);clock.advance_minutes(40) + ManaOutage.release(runtime, state);clock.advance_minutes(40) expect(worker.health>health,"power recovery resumes rest") runtime.facility_service.set_operational_status(room_id,{&"operator_open":false},clock.absolute_minute) health=worker.health;clock.advance_minutes(30) diff --git a/tests/supply/test_supply_logistics_bridge.gd b/tests/supply/test_supply_logistics_bridge.gd index 7f72f0f..73612a9 100644 --- a/tests/supply/test_supply_logistics_bridge.gd +++ b/tests/supply/test_supply_logistics_bridge.gd @@ -1,4 +1,5 @@ extends SceneTree +const ManaOutage = preload("res://tests/helpers/mana_outage.gd") ## Real construction, hired porter and purchased stock; failure injection is ## limited to route closures, capacity pressure and expired cargo. @@ -363,12 +364,12 @@ func _finish() -> void: func _test_reserve_outage() -> void: _expect(_runtime.restore_snapshot(_loaded), "restore physically loaded cargo for reserve outage") - _state.set_resource(&"mana", 0) + ManaOutage.hold(_runtime, _state) _clock.advance_minutes(30) _expect(_request().state == LogisticsRequest.State.DELIVERING and _quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0, "outage retains loaded cargo without delivering into stopped facility") _expect(_crosslinks(), "outage preserves Request Task inventory ownership") _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "cargo waiting through outage restores") - _state.set_resource(&"mana", 100) + ManaOutage.release(_runtime, _state) _wait_for(LogisticsRequest.State.COMPLETED, 60) _expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_cargo()) == 0 and _quantity(_inventory(_destination)) == 8, "recovered power delivers cargo exactly once") _expect(_crosslinks(), "recovered delivery ownership reconciles") @@ -389,7 +390,7 @@ func _test_kitchen_reserve_outage() -> void: if before[&"active_batch"].is_empty(): _runtime.restore_snapshot(_loaded) return - _state.set_resource(&"mana", 0) + ManaOutage.hold(_runtime, _state) _clock.advance_minutes(30) var stopped: Dictionary = _runtime.kitchen_production.get_report(_destination) _expect(stopped[&"state"] == &"NO_POWER" and stopped[&"active_batch"] == before[&"active_batch"], "thirty stopped minutes preserve batch identity reservations and progress") @@ -397,7 +398,7 @@ func _test_kitchen_reserve_outage() -> void: _expect(_runtime.kitchen_production.validate_crosslinks(), "stopped kitchen reservations remain owned") var saved: Dictionary = _runtime.create_snapshot() _expect(_runtime.restore_snapshot(saved) and _runtime.create_snapshot() == saved, "active cooking outage round trips through complete Runtime save") - _state.set_resource(&"mana", 100) + ManaOutage.release(_runtime, _state) _clock.advance_minutes(30) _expect(_quantity(_inventory(_destination)) == 4 and _runtime.inventory_service.total_quantity(_inventory(_destination), SupplyCatalog.BASIC_MEAL) == 4, "recovery consumes exactly four raw food and produces exactly four meals") _expect(_runtime.kitchen_production.get_report(_destination)[&"state"] == &"TARGET_REACHED" and _runtime.kitchen_production.validate_crosslinks(), "recovered batch releases reservations and stops at target") diff --git a/tests/transport/test_runtime_transport.gd b/tests/transport/test_runtime_transport.gd index 96d8f7f..d03ef08 100644 --- a/tests/transport/test_runtime_transport.gd +++ b/tests/transport/test_runtime_transport.gd @@ -1,4 +1,5 @@ extends SceneTree +const ManaOutage = preload("res://tests/helpers/mana_outage.gd") const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd") var _finishing := false @@ -128,7 +129,7 @@ func _verify_mid_ride() -> bool: _expect(_runtime.restore_snapshot(snapshot), "rewind depletion drill to original passenger state") for cell: Vector3i in shaft[&"stops"]: _runtime.path_service.configure_cell_traffic(cell, 4, 0, true) - _state.set_resource(&"mana", 0) + ManaOutage.hold(_runtime, _state) _clock.advance_minutes(1) for actor_id: StringName in riders: _expect(_runtime.elevators.get_actor_trip(actor_id).get(&"state") == PathService.ELEVATOR_REQUEST_BOARDED, "no safe landing retains boarded passenger instead of teleporting") @@ -140,7 +141,7 @@ func _verify_mid_ride() -> bool: _expect(_runtime.restore_snapshot(held_snapshot) and _runtime.create_snapshot() == held_snapshot, "no-landing outage preserves full Runtime save and passenger ownership") for cell: Vector3i in shaft[&"stops"]: _runtime.path_service.configure_cell_traffic(cell) - _state.set_resource(&"mana", 100) + ManaOutage.release(_runtime, _state) _clock.advance_minutes(45) for actor_id: StringName in riders: _expect(_runtime.elevators.get_actor_trip(actor_id).is_empty() and _runtime.path_service.get_active_elevator_request(actor_id).is_empty(), "landing repair and power restoration release held trip ownership")