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 <noreply@anthropic.com>
This commit is contained in:
Takeshi
2026-10-19 20:56:57 +09:00
co-authored by Claude Opus 5.5
parent 65534cfd15
commit f247d19325
10 changed files with 104 additions and 21 deletions
+1
View File
@@ -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
+19 -9
View File
@@ -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(
@@ -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"
@@ -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 と配電投影の保存は、炉の実接続が別承認なので対象外です。
@@ -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",
+4 -3
View File
@@ -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
+9 -2
View File
@@ -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
+4 -2
View File
@@ -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)
+7 -1
View File
@@ -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()
+42
View File
@@ -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: