From f247d19325df076f498178c371e106a2dac4b8c0 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Mon, 19 Oct 2026 20:56:57 +0900 Subject: [PATCH] P06-01: Fix review r01 findings for A06 reserve Settle Mana raised above capacity between boundaries as recorded overflow instead of rejecting saves or latching a fault, exclude destroyed facilities from upkeep demand, and keep the depletion drill's outage sustained. Co-Authored-By: Claude Opus 5.5 --- Makefile | 1 + autoload/simulation_runtime.gd | 28 +++++++++---- .../decisions.json | 2 +- .../execution/p06-01-a06/IMPLEMENTATION.md | 18 +++++++- .../plan_validation.json | 2 +- scripts/mana/mana_network_service.gd | 7 ++-- scripts/mana/mana_reserve_cycle.gd | 11 ++++- tests/mvp/test_runtime_mana_depletion.gd | 6 ++- tests/s2/test_mana_reserve_cycle.gd | 8 +++- tests/s2/test_runtime_mana_reserve.gd | 42 +++++++++++++++++++ 10 files changed, 104 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index f572da3..d8ca962 100644 --- a/Makefile +++ b/Makefile @@ -99,6 +99,7 @@ test-s6: test-mvp: $(GODOT) --headless --path . --script res://tests/mvp/test_premium_room_guest_flow.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_runtime_mana_depletion.gd $(GODOT) --headless --path . --script res://tests/mvp/test_premium_room.gd $(GODOT) --headless --path . --script res://tests/mvp/test_original_room_zones.gd $(GODOT) --headless --path . --script res://tests/mvp/test_upkeep_loss_streak.gd diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 6076a80..f7fa942 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -866,18 +866,18 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo # (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))): + if not saved_game_resources is Dictionary: return false + var saved_mana: Variant = saved_game_resources.get(&"mana", saved_game_resources.get("mana", null)) if runtime_data.has(&"mana_reserve"): - if not restored_mana_reserve.restore_snapshot(runtime_data[&"mana_reserve"]) \ + # Between boundaries Mana may exceed capacity; the next boundary records + # and discards the excess, so only a valid integer amount is required. + if not ManaReserveCycle.stored_amount_is_valid(saved_mana) \ + or 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): + elif not ManaReserveCycle.amount_is_valid(saved_mana) \ + or not restored_mana_reserve.start_at(restored_minute): return false var restored_economy := EconomyService.new() if not restored_economy.restore_snapshot(runtime_data[&"economy"]): @@ -10741,6 +10741,16 @@ func _reservation_references_are_valid( return false return true +## A06 upkeep demand: every owned consumer, open or not, except destroyed +## facilities. Removed facilities are already unregistered from the network. +func _mana_reserve_demand() -> int: + var destroyed: Array[StringName] = [] + for facility: FacilityInstance in facility_service.get_instances(): + if facility.suspension_reason == &"DESTROYED": + destroyed.append(facility.instance_id) + return mana_network_service.get_total_consumer_demand(destroyed) + + func _advance_mana(absolute_minute: int) -> void: if _operations_tick_blocked: return @@ -10760,7 +10770,7 @@ func _advance_mana(absolute_minute: int) -> void: # 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() + absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand() ) if not bool(reserve_flow.get(&"ok", false)): _latch_infrastructure_failure( diff --git a/docs/plans/original-completion-2026-10-02/decisions.json b/docs/plans/original-completion-2026-10-02/decisions.json index 2b60de0..35499a7 100644 --- a/docs/plans/original-completion-2026-10-02/decisions.json +++ b/docs/plans/original-completion-2026-10-02/decisions.json @@ -37,7 +37,7 @@ { "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のみ保持し、超過・不正値は拒否。炉の人物適格性・収率の実接続は別承認。", + "decision": "初期100・容量300・世界樹生成20/ゲーム内10分。10分境界ごとにB+G+F−Dを計算し、需要を先に支払い、超過は明示破棄、0で全館停止。需要は保有施設のmaintenance_mana合計(閉店・停止中も計上、撤去・破壊済みは除外)。境界間に返却等で容量を超えた分は次の境界でoverflowとして記録・破棄。炉の投入は自然生成と需要支払後の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" 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 index 36147c8..c235fac 100644 --- 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 @@ -4,9 +4,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 も含みます。 +- `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(excluded_ids)` を追加しました。登録済みの consumer の需要を合計します。無効化・停止中の consumer も含みます。Runtime の `_mana_reserve_demand()` が、破壊済み(DESTROYED)施設を除外して呼び出します。撤去済みの施設はネットワークから登録解除されます。 - `autoload/simulation_runtime.gd`:MANA phase の最初で残量を確定してから、局所配電と全館ゲートを評価します。phase の順序は変えていません。新しいセーブは `mana_reserve` を持ちます。Operations の巻き戻し対象にも含めました。 +- 現行セーブは整数の残量を受け付けます。境界の間に容量を超えた値も読み込めます。 - 旧セーブの移行:`mana_reserve` が無いセーブは、残量が0〜300の整数であれば、その値を保持して開始します。明示的な0は停止のまま保持します。300超・負値・非整数は拒否し、現在の状態を保持します。 ## 試験 @@ -18,3 +19,16 @@ - 炉の人物適格性・収率の実接続、未定義13施設の需要、D05 の詳細は別承認です。 - 全館の実プレイ、性能、Windows での受入は未実施です。原88の正式完了数は変わりません。 + +## 独立レビュー r01 の指摘と修正 + +候補 65534cf への判定は FAIL でした。修正した内容は次のとおりです。 + +1. 境界の間にマナが増えた場合の不整合を直しました。以前は、読込を拒否するか、毎分 INVALID_FLOW を起こして全館が停止していました。現在は、次の境界で overflow として記録・破棄します。 +2. `test_runtime_mana_depletion` は停電が続く前提でした。`ManaOutage` に置き換え、旧セーブも `mana_reserve` を外して作るようにしました。また、Makefile の test-mvp に追加しました。 +3. 需要に破壊済みの施設が含まれていました。提案書の「撤去・破壊済みは除外」に合わせて除外し、A06 の記録文も同じ内容に訂正しました。 + +## 未検証として残す点 + +- 世界樹の生成を0にする経路が無いため、提案書の比較表にある「生成0→0→通常」は適用外です。 +- 炉の receipt と配電投影の保存は、炉の実接続が別承認なので対象外です。 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 0af5038..343a5b3 100644 --- a/docs/plans/original-completion-2026-10-02/plan_validation.json +++ b/docs/plans/original-completion-2026-10-02/plan_validation.json @@ -238,7 +238,7 @@ "build_atomic_requirements.py": "6d6ea9e4216372dbd0f9fed3f885a4481b21a3f539a26f64cf98a6830af984c5", "build_facility_value_contracts.py": "35027b23ce75ce9e4e0cdfc5132fd2eb771ba9f598d97750d7ecf9ecb257f769", "current_evidence_links.json": "db1837bcc8497201134297e312f4b266a297102285307ec3075d4e23632e26c2", - "decisions.json": "927065d9f840e68667d10998d26eb5d04f5591d9f24082106d082e51d5af5c96", + "decisions.json": "7771ef179b73f061363685a218f7b0d300847fc5881ce0c8b108ca62b2076dfe", "facility_value_contracts.json": "447388ba03a29bfac25f2bcd068665aedbb07ba7641ce86f2db93d44f4c9f326", "facility_value_contracts_validation.json": "bf490a9ca585c48fc318e76c4a069ef244827d828d46e6415399f7710590dd16", "performance_audit.json": "0b630b15a392c3e0366f1362bfe4ddacb968f915f54549f82e4d6fa315384762", diff --git a/scripts/mana/mana_network_service.gd b/scripts/mana/mana_network_service.gd index 8b789fc..32bb045 100644 --- a/scripts/mana/mana_network_service.gd +++ b/scripts/mana/mana_network_service.gd @@ -231,11 +231,12 @@ 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: +## Upkeep demand of every registered consumer, including disabled ones, +## except the given consumer ids (for example destroyed facilities). +func get_total_consumer_demand(excluded_ids: Array = []) -> int: var total := 0 for node_id: StringName in _nodes: - if int(_nodes[node_id][&"kind"]) == NodeKind.CONSUMER: + if int(_nodes[node_id][&"kind"]) == NodeKind.CONSUMER and not node_id in excluded_ids: total += int(_nodes[node_id][&"demand"]) return total diff --git a/scripts/mana/mana_reserve_cycle.gd b/scripts/mana/mana_reserve_cycle.gd index f70fcfe..586b809 100644 --- a/scripts/mana/mana_reserve_cycle.gd +++ b/scripts/mana/mana_reserve_cycle.gd @@ -34,10 +34,12 @@ func get_last_flow() -> Dictionary: ## Processes every boundary in (last boundary, minute]. Boundaries already ## processed are no-ops, so per-minute callers and same-minute retries are safe. +## Mana may rise between boundaries (event refunds, commands); anything above +## capacity is discarded and recorded as overflow at the next boundary. 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 \ + if typeof(minute) != TYPE_INT or not _amount(before) \ 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 @@ -69,9 +71,14 @@ static func plan(before: int, demand: int, furnace: int, start: int, end: int) - &"consumed": consumed, &"shortage": demand - consumed, &"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)} +## Legacy saves without a reserve receipt must already fit the approved capacity. static func amount_is_valid(value: Variant) -> bool: return _amount(value) and value <= CAPACITY +## A current save may hold more than capacity until the next boundary settles it. +static func stored_amount_is_valid(value: Variant) -> bool: + return _amount(value) + func create_snapshot() -> Dictionary: return {&"version": VERSION, &"policy_id": POLICY_ID, &"capacity": CAPACITY, &"generation": WORLD_TREE_GENERATION, &"interval_minutes": INTERVAL_MINUTES, @@ -97,7 +104,7 @@ func restore_snapshot(source: Variant) -> bool: for key: Variant in flow: if not _amount(flow[key]): return false - if flow["to_minute"] != source["minute"] or flow["before"] > CAPACITY \ + if flow["to_minute"] != source["minute"] \ or flow["generation"] != WORLD_TREE_GENERATION \ or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]: return false diff --git a/tests/mvp/test_runtime_mana_depletion.gd b/tests/mvp/test_runtime_mana_depletion.gd index 18a4b63..6bdc902 100644 --- a/tests/mvp/test_runtime_mana_depletion.gd +++ b/tests/mvp/test_runtime_mana_depletion.gd @@ -1,4 +1,5 @@ extends SceneTree +const ManaOutage = preload("res://tests/helpers/mana_outage.gd") var checks := 0 var failures := 0 func _initialize() -> void: @@ -31,11 +32,12 @@ func _run() -> void: runtime.free(); clock.free(); state.free(); quit(failures); return # Only reserve depletion is a diagnostic input; no numeric generation policy # is asserted by this test. Everything else advances through Runtime ticks. - state.set_resource(&"mana", 0) + ManaOutage.hold(runtime, state) # Pre-fix saves legitimately contain zero reserve with old powered flags. var legacy: Dictionary = runtime.create_snapshot() var legacy_data: Dictionary = legacy[&"systems"][&"runtime"] legacy_data[&"runtime_schema_version"] = 9 + legacy_data.erase(&"mana_reserve") preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(legacy_data) var source_copy := legacy.duplicate(true) var old_reception_id := reception.instance_id @@ -65,7 +67,7 @@ func _run() -> void: var room_id := room.instance_id var reception_id := reception.instance_id runtime.facility_service.set_operational_status(room_id, {&"suspension_reason": &"DESTROYED"}, clock.absolute_minute) - state.set_resource(&"mana", 1) + ManaOutage.release(runtime, state, 1) clock.advance_minutes(1) room = runtime.facility_service.get_instance(room_id) reception = runtime.facility_service.get_instance(reception_id) diff --git a/tests/s2/test_mana_reserve_cycle.gd b/tests/s2/test_mana_reserve_cycle.gd index 11fc9ca..f88cb6d 100644 --- a/tests/s2/test_mana_reserve_cycle.gd +++ b/tests/s2/test_mana_reserve_cycle.gd @@ -34,7 +34,13 @@ func _initialize() -> void: 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") + expect(not cycle.advance_to(380, -1, 0)[&"ok"] and cycle.create_snapshot() == saved, "negative reserve rejected without change") + var high = Cycle.new() + high.start_at(360) + flow = high.advance_to(370, 525, 16)[&"flow"] + expect(flow[&"after"] == 300 and flow[&"overflow"] == 229, "amount raised above capacity between boundaries settles with recorded overflow") + var high_restored = Cycle.new() + expect(high_restored.restore_snapshot(high.create_snapshot()), "over-capacity before survives snapshot validation") 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() diff --git a/tests/s2/test_runtime_mana_reserve.gd b/tests/s2/test_runtime_mana_reserve.gd index 570b4cc..f96ec1b 100644 --- a/tests/s2/test_runtime_mana_reserve.gd +++ b/tests/s2/test_runtime_mana_reserve.gd @@ -18,6 +18,8 @@ func _run() -> void: _test_generation_and_demand() _test_save_round_trip_and_migration() _test_depletion_stops_tower() + _test_between_boundary_increase() + _test_destroyed_facility_demand() _cleanup() if _failures == 0: print("PASS: %d checks" % _checks) @@ -100,6 +102,46 @@ func _test_depletion_stops_tower() -> void: _expect(_state.get_resource(&"mana") == maxi(0, 20 - demand), "world tree recovers the reserve at the next boundary") +func _test_between_boundary_increase() -> void: + _clock.advance_minutes(10 - (_clock.absolute_minute % 10) + 3) + _state.add_resource(&"mana", 400) + var high: int = _state.get_resource(&"mana") + _expect(high > Cycle.CAPACITY, "mana can rise above capacity between boundaries (event refund / command)") + var saved: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(saved.duplicate(true)) and _state.get_resource(&"mana") == high, "over-capacity mid-interval save restores") + _clock.advance_minutes(1) + _expect(not _runtime.get_operations_report()[&"runtime_failure_latched"], "over-capacity amount does not latch a runtime fault") + _clock.advance_minutes(10 - (_clock.absolute_minute % 10)) + var flow: Dictionary = _runtime.mana_reserve.get_last_flow() + _expect(_state.get_resource(&"mana") == Cycle.CAPACITY and flow[&"before"] == high, "next boundary settles to capacity") + _expect(flow[&"overflow"] == high + 20 - int(flow[&"consumed"]) - Cycle.CAPACITY, "excess is recorded as overflow") + + +func _test_destroyed_facility_demand() -> void: + var build = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"cafeteria", &"origin": Vector3i(-5, 0, WorldService.LAYER_FRONT), &"rotation": 0}, + &"player", &"", 50, -1, &"a06.build.cafeteria" + ) + _expect(build.is_success(), "cafeteria construction starts") + _clock.advance_minutes(180) + var cafeteria: FacilityInstance = null + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"cafeteria": + cafeteria = facility + _expect(cafeteria != null, "cafeteria completes") + if cafeteria == null: + return + var with_cafeteria: int = _runtime.call("_mana_reserve_demand") + _expect(with_cafeteria == _runtime.mana_network_service.get_total_consumer_demand(), "owned facility demand counts") + _runtime.facility_service.set_operational_status(cafeteria.instance_id, {&"business_open": false}, _clock.absolute_minute) + _expect(_runtime.call("_mana_reserve_demand") == with_cafeteria, "closed facility still pays upkeep demand") + _runtime.facility_service.set_operational_status(cafeteria.instance_id, {&"suspension_reason": &"DESTROYED"}, _clock.absolute_minute) + _expect(_runtime.call("_mana_reserve_demand") == with_cafeteria - 2, "destroyed facility demand is excluded") + _clock.advance_minutes(10 - (_clock.absolute_minute % 10)) + _expect(int(_runtime.mana_reserve.get_last_flow()[&"demand"]) == with_cafeteria - 2, "boundary uses demand without destroyed facility") + + func _expect(condition: bool, label: String) -> void: _checks += 1 if condition: