P07-01-WX: 仕上がった天候の機能の受入:自然天候・雨・嵐・世界樹と成人客のマナ霧・上部の表示と予報・保存と移行(マナ炉の産出 120 を除く。P07-01 の補助の作業。原単位への加算 0)

This commit is contained in:
Agent Control Runner
2026-10-24 05:04:48 +09:00
parent 7ac2224e47
commit c50136017b
25 changed files with 1868 additions and 28 deletions
+86 -5
View File
@@ -37,6 +37,7 @@ const CleaningBridge = preload("res://scripts/environment/facility_cleaning_brid
const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd") const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd")
const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10 const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10
const WeatherTimeline = preload("res://scripts/environment/weather_timeline.gd") const WeatherTimeline = preload("res://scripts/environment/weather_timeline.gd")
const NaturalWeather = preload("res://scripts/environment/natural_weather_policy.gd")
const STAIRS_RUNTIME_SNAPSHOT_VERSION := 9 const STAIRS_RUNTIME_SNAPSHOT_VERSION := 9
const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8 const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8
const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7 const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7
@@ -85,6 +86,7 @@ const RNG_STREAM_IDS: Array[StringName] = [
&"defense", &"defense",
&"event", &"event",
&"loot", &"loot",
&"weather",
] ]
const DEFENSE_COMMAND_TYPES: Array[StringName] = [ const DEFENSE_COMMAND_TYPES: Array[StringName] = [
&"create_defense_raid", &"create_defense_raid",
@@ -463,6 +465,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
if not report[&"valid"]: if not report[&"valid"]:
_clock.set_paused(true) _clock.set_paused(true)
definition_validation_failed.emit(report) definition_validation_failed.emit(report)
# A13: a new game opening at 06:00 draws its first day once streams exist.
_draw_natural_weather_if_due(_clock.absolute_minute)
_publish_snapshot(_clock.absolute_minute) _publish_snapshot(_clock.absolute_minute)
return report return report
@@ -1291,6 +1295,15 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
return false return false
elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute): elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute):
return false return false
# A13 rain holds must still describe a present guest's extended plan.
var rain_holds := restored_weather.get_rain_holds()
for held_key: Variant in rain_holds:
var held_guest := restored_guests.get_guest(StringName(held_key))
if held_guest == null or held_guest.planned_exit_at != int(rain_holds[held_key]["extended_to"]):
return false
var saved_weather: Variant = runtime_data.get(&"weather", null)
var weather_stream_required: bool = saved_weather is Dictionary \
and int(saved_weather.get("version", 0)) >= WeatherTimeline.VERSION
var restored_cleanliness := FacilityCleanliness.new() var restored_cleanliness := FacilityCleanliness.new()
if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION: if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION:
if not runtime_data.get(&"facility_cleanliness") is Dictionary or not restored_cleanliness.restore_snapshot(runtime_data[&"facility_cleanliness"]): if not runtime_data.get(&"facility_cleanliness") is Dictionary or not restored_cleanliness.restore_snapshot(runtime_data[&"facility_cleanliness"]):
@@ -1323,6 +1336,12 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
if has_defense_snapshot: if has_defense_snapshot:
return false return false
restored_streams[&"defense"] = DeterministicRng.from_root_seed(restored_world_seed, &"defense") restored_streams[&"defense"] = DeterministicRng.from_root_seed(restored_world_seed, &"defense")
# A13 migration: saves before natural weather start the weather stream from
# the world seed, as defense did (SPEC section 5.2).
if not restored_streams.has(&"weather"):
if weather_stream_required:
return false
restored_streams[&"weather"] = DeterministicRng.from_root_seed(restored_world_seed, &"weather")
if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION: if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION:
for required_stream_id: StringName in RNG_STREAM_IDS: for required_stream_id: StringName in RNG_STREAM_IDS:
if not restored_streams.has(required_stream_id): if not restored_streams.has(required_stream_id):
@@ -2951,6 +2970,8 @@ func _operations_arrival_boundary(
&"ok": not captured.is_empty(), &"ok": not captured.is_empty(),
&"token": captured, &"token": captured,
&"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0) if int(_state.get_resource(&"mana")) > 0 and weather.permits_new_arrivals() else 0, &"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0) if int(_state.get_resource(&"mana")) > 0 and weather.permits_new_arrivals() else 0,
# A13: rain halves the planned new arrivals of this boundary.
&"arrival_percent": NaturalWeather.arrival_percent(weather.get_kind()),
} }
if phase == OperationsCoordinator.PHASE_VALIDATE: if phase == OperationsCoordinator.PHASE_VALIDATE:
return { return {
@@ -8784,6 +8805,7 @@ func _advance_guest_vitals(absolute_minute: int) -> void:
&"is_night": minute_of_day < 360 or minute_of_day >= 1_080, &"is_night": minute_of_day < 360 or minute_of_day >= 1_080,
&"intimacy_day": 3, &"intimacy_day": 3,
&"intimacy_night": 7, &"intimacy_night": 7,
&"mana_fog": weather.get_kind() == &"mana_fog",
} }
}) })
@@ -9035,7 +9057,7 @@ func _advance_guest_ai_decisions(absolute_minute: int) -> void:
GuestRuntime.State.ENTERING: GuestRuntime.State.ENTERING:
guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED") guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED")
GuestRuntime.State.DECIDING: GuestRuntime.State.DECIDING:
if guest.planned_exit_at <= absolute_minute: if guest.planned_exit_at <= absolute_minute and not _hold_guest_for_rain(guest, absolute_minute):
guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT") guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT")
continue continue
guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id)) guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id))
@@ -10993,13 +11015,14 @@ func _reservation_references_are_valid(
## HUD view of the A06 reserve: supply, consumption and storage per interval. ## HUD view of the A06 reserve: supply, consumption and storage per interval.
func get_mana_reserve_report() -> Dictionary: func get_mana_reserve_report() -> Dictionary:
var demand := _mana_reserve_demand() var demand := _mana_reserve_demand()
var generation := NaturalWeather.mana_yield(ManaReserveCycle.WORLD_TREE_GENERATION, weather.get_kind())
return { return {
&"amount": int(_state.get_resource(&"mana")), &"amount": int(_state.get_resource(&"mana")),
&"capacity": ManaReserveCycle.CAPACITY, &"capacity": ManaReserveCycle.CAPACITY,
&"generation": ManaReserveCycle.WORLD_TREE_GENERATION, &"generation": generation,
&"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES, &"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES,
&"demand": demand, &"demand": demand,
&"net": ManaReserveCycle.WORLD_TREE_GENERATION - demand, &"net": generation - demand,
&"last_flow": mana_reserve.get_last_flow(), &"last_flow": mana_reserve.get_last_flow(),
} }
@@ -11032,8 +11055,10 @@ func _advance_mana(absolute_minute: int) -> void:
return return
# A06: generation and upkeep demand settle before local distribution and the # A06: generation and upkeep demand settle before local distribution and the
# whole-tower gate, so the network and projection see the committed reserve. # whole-tower gate, so the network and projection see the committed reserve.
# A13: mana fog raises the world tree yield of this boundary to x1.2 (floor).
var reserve_flow := mana_reserve.advance_to( var reserve_flow := mana_reserve.advance_to(
absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand() absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand(),
NaturalWeather.mana_yield(ManaReserveCycle.WORLD_TREE_GENERATION, weather.get_kind())
) )
if not bool(reserve_flow.get(&"ok", false)): if not bool(reserve_flow.get(&"ok", false)):
_latch_infrastructure_failure( _latch_infrastructure_failure(
@@ -13572,10 +13597,66 @@ func get_staff_rest_report(staff_id: StringName) -> Dictionary:
func _advance_weather(absolute_minute: int) -> void: func _advance_weather(absolute_minute: int) -> void:
weather.advance_to(absolute_minute) weather.advance_to(absolute_minute)
_draw_natural_weather_if_due(absolute_minute)
if not weather.get_rain_holds().is_empty():
var present := {}
for guest: GuestRuntime in guest_service.get_guests():
present[guest.guest_id] = true
weather.prune_rain_holds(present)
## A13: one draw per day at 06:00 from the saved weather stream. The weather
## boundary runs first, so the same minute's arrivals and closes see the result.
func _draw_natural_weather_if_due(absolute_minute: int) -> void:
if not NaturalWeather.is_draw_minute(absolute_minute) or weather.get_minute() != absolute_minute:
return
var day := NaturalWeather.day_of(absolute_minute)
var stream := get_rng_stream(&"weather")
if day <= weather.get_last_draw_day() or stream == null:
return
var draw := NaturalWeather.draw(stream, day)
var applied := weather.apply_draw(draw)
var no_entities: Array[StringName] = []
_publish_actor_event(&"WEATHER_FORECAST_DRAWN", &"weather", no_entities, {
&"day": day,
&"kind": String(draw[&"kind"]),
&"roll": int(draw[&"roll"]),
&"slot": int(draw[&"slot"]),
&"start": int(draw[&"start"]),
&"end": int(draw[&"end"]),
&"applied": bool(applied.get(&"ok", false)),
&"code": String(applied.get(&"code", &"")),
})
## A13: a guest due to leave while it rains stays until the rain ends, at most
## 240 minutes after the original plan, once per guest (SPEC section 4.3).
func _hold_guest_for_rain(guest: GuestRuntime, absolute_minute: int) -> bool:
if weather.get_kind() != &"rain" or not weather.get_rain_hold(guest.guest_id).is_empty():
return false
var original := guest.planned_exit_at
var extended := original + NaturalWeather.RAIN_HOLD_MAX_MINUTES
var rain_end := weather.rain_end_minute()
if rain_end >= 0:
extended = mini(extended, rain_end)
if extended <= absolute_minute or not weather.add_rain_hold(guest.guest_id, original, extended):
return false
guest.planned_exit_at = extended
return true
func get_weather_report() -> Dictionary: func get_weather_report() -> Dictionary:
return {&"kind": weather.get_kind(), &"minute": weather.get_minute()} var kind := weather.get_kind()
return {
&"kind": kind,
&"minute": weather.get_minute(),
&"since": weather.get_since(),
&"next_change": weather.next_change(),
&"today_draw": weather.get_today_draw(),
&"effect_text": NaturalWeather.effect_text(kind),
&"arrival_percent": NaturalWeather.arrival_percent(kind),
&"rain_holds": weather.get_rain_holds().size(),
}
## Read-only migration preview; applying dimensions remains a separate transaction. ## Read-only migration preview; applying dimensions remains a separate transaction.
@@ -3958,6 +3958,39 @@
"path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md", "path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md",
"scope": "new P05-02 F6 restart reader marker; native execution pending" "scope": "new P05-02 F6 restart reader marker; native execution pending"
} }
},
{
"test": "tests/environment/test_p07_weather_runtime.gd",
"template": "PASS: <checks> checks",
"required": true,
"fixed_check_count_used": false,
"source_sha256_at_schema_audit": "11caac7e652213a708c270e136853b0fa053bcda502576da741f1f5d67cdd64b",
"authority": {
"path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md",
"scope": "new P07-01 suite marker; native execution pending"
}
},
{
"test": "tests/environment/test_p07_weather_restart_1_write.gd",
"template": "PASS: <checks> checks",
"required": true,
"fixed_check_count_used": false,
"source_sha256_at_schema_audit": "ad8067f42348b803ce7736bee2f55f4b0789fefaeaaac702ff8a8800d926d8ea",
"authority": {
"path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md",
"scope": "new P07-01 F7 restart writer marker; native execution pending"
}
},
{
"test": "tests/environment/test_p07_weather_restart_2_read.gd",
"template": "PASS: <checks> checks",
"required": true,
"fixed_check_count_used": false,
"source_sha256_at_schema_audit": "fb0a633c08cca1de66dc549f760f2a8e822c6c14c54f58e609de7f78c22fb469",
"authority": {
"path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md",
"scope": "new P07-01 F7 restart reader marker; native execution pending"
}
} }
], ],
"unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting", "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting",
@@ -0,0 +1,46 @@
{
"task": "P07-01",
"spec": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md",
"rule": "SPEC F8: every changed expectation of an existing suite is listed with the value before and after and the reason. Only the natural-weather contract (A13, SPEC sections 4 and 5) causes these changes.",
"changes": [
{
"suite": "tests/environment/test_runtime_weather.gd",
"check": "old neutral save migration preserves other state",
"before": "runtime.create_snapshot() == original (the whole snapshot after migrating a version 10 save equals the original new game)",
"after": "the whole snapshot without systems.runtime.weather equals the original without it, and the migrated weather has last_draw_day -1 and no drawn day",
"reason": "A13: a new game opening at 06:00 draws its first day (SPEC 4.1), and a pre-weather save has no draw and must not draw for the past (SPEC 5.2). Only the weather section can differ; every other system stays byte-equal."
},
{
"suite": "tests/environment/test_runtime_weather.gd",
"check": "future weather clock rejects atomically",
"before": "the refused restore leaves runtime.create_snapshot() == original",
"after": "the refused restore leaves runtime.create_snapshot() == the migrated snapshot taken just before",
"reason": "The state before this refusal is the migrated state of the previous check, which now differs from the original only in the weather section; the check still proves the refusal is atomic."
},
{
"suite": "tests/environment/test_weather_timeline.gd",
"check": "malformed or contradictory state rejects atomically (unknown version case)",
"before": "{\"version\": 2} is an unknown version",
"after": "{\"version\": 3} is an unknown version",
"reason": "Version 2 is now the current weather snapshot (natural state and rain holds, SPEC 5.1); version 1 is read as the legacy format (SPEC 5.2)."
},
{
"suite": "tests/environment/test_weather_recovery.gd",
"check": "disk-restored recovery occurs once at scheduled minute",
"before": "after the recovery the pending weather list is empty",
"after": "after the recovery the pending weather list equals only the opening day's natural draw (rain 16:30-22:30 for seed 424242)",
"reason": "A13: the opening day's natural draw schedules its own start and return to clear (SPEC 4.1); the manually scheduled storm and recovery are still consumed exactly once."
}
],
"unchanged_related_suites_passing_locally": [
"tests/mvp/test_runtime_operations_integration.gd",
"tests/s2/test_runtime_operations_cycle.gd",
"tests/s2/test_runtime_mana_reserve.gd",
"tests/s2/test_mana_reserve_cycle.gd",
"tests/s3/test_guest_service.gd",
"tests/s6/test_runtime_save_lifecycle.gd",
"tests/s6/test_runtime_snapshot_serialization.gd",
"tests/ui/test_main_dashboard.gd"
],
"local_run_scope": "diagnostic direct runs on a scratch stage; the formal evidence is the submitted S native collection, the registered validation and the final full roster"
}
@@ -0,0 +1,42 @@
# P07-01 実装の記録(実 Opus の実装担当、2026-10-24)
- 仕様: 同じフォルダの `SPEC.md`(固定の commit `4654789`、Runner の `spec_sha256` `2626d70b…7956`)。決定 A13(`PROVENANCE.json`)。
- この記録は、仕様の §4・§5 を実装した場所と、実装担当として選んだ詳細、開示を書く。人の新しい承認は主張しない。
## 1. 実装した場所
- `scripts/environment/natural_weather_policy.gd`(新しい): A13 の値(確率の境 60・85・95、続く時間 360・240・120 分、開始の 29 個の 30 分刻み、雨の来客 50%、雨の延長の上限 240 分、霧のマナ ×1.2・性欲 ×1.5)と、1日1回の抽選の手順。天候の仕組みは確率を持たない方針のままにするため分けた。
- `scripts/environment/weather_timeline.gd`(VERSION 2): 自然天候の状態(最後に抽選した日、その日の抽選)と雨の延長の記録を持つ。予定の規則(同じ分の別の種類・過去の時刻・上限)は変えない。その日の抽選の当てはめ(`apply_draw`)は、その日の 06:00 にだけ、1回だけ受け付ける。予定と重なれば `SCHEDULE_CONFLICT` で拒む。VERSION 1 の保存は、過去の抽選をさかのぼらない形で読む。
- `autoload/simulation_runtime.gd`:
- 系列 `weather` を足した。
- 06:00 の抽選と出来事 `WEATHER_FORECAST_DRAWN`。新しいゲームが 06:00 に始まる時は、初期化の最後に開始の日を抽選する。
- 来客の受付の境界は、雨の割合(`arrival_percent`)を渡す。
- 帰りの判定は、雨の延長(`_hold_guest_for_rain`)を通す。
- マナの境界は、霧の産出を渡す。
- 客の生理の文脈に `mana_fog` を足した。
- 予報を含む `get_weather_report`。
- 復元では、延長の記録と客の予定を照合し、旧保存の系列を `world_seed` から作る。
- `scripts/operations/operations_coordinator.gd`: 来客の計画を2段にした(行ごとの受け入れと持ち越しを決め、雨なら合計を floor(×50/100) まで後ろの行から減らし、減らした分は持ち越さない)。雨でない時の計画と客の ID は今と同じ。
- `scripts/mana/mana_reserve_cycle.gd`: 境界の世界樹の産出を引数で受ける(既定 20、霧 24)。炉の受け入れの判定と `plan` も霧の量(24・120)を受ける。保存の照合は産出 20 と 24 だけを許す。上限 300(A06)は変えない。
- `scripts/guest/guest_service.gd`: 文脈の真偽値 `mana_fog` を検査し、成人確認済みの客の正の性欲の増分 n を n + floor(n / 2)(= floor(1.5n))にする。
- `scenes/main/main.gd`: 上部の天候の名前の表示はそのままにし、隣に予報(`WeatherForecastLabel`、「次: 晴れ 12:00」「次: 予定なし」)を足した。天候の名前の案内(tooltip)に効果の案内を出す。
## 2. 実装担当として選んだ詳細(SPEC §2 のとおり人の承認ではない)
- 抽選の乱数: 0〜99 の整数を1つ、晴れ以外は開始の枠(0〜28)を1つ引く。
- 日の数え方: 絶対分 ÷ 1440 の切り捨て。06:00 ちょうどは、まだ抽選していない日として数える(新しいゲームの開始の日のため)。旧保存の移行では、06:00 を過ぎていればその日は抽選済みとして数える。
- 当てはめに失敗した抽選(手で置いた予定と同じ分になる時など)は、系列を進めたまま `applied: false` と理由を出来事に残し、その日の予定は変えない。次の日の 06:00 には抽選する。
- 雨の延長は、帰りの判定(客が「決める」状態の時)で行う。延長の後の時刻には天候に関わらず今の規則で帰る。帰った客の記録は、天候の境界で片づける。
- 霧の性欲は、その分の正の増分だけに掛ける(減少と、成人確認の無い客は変えない)。
## 3. 開示
- マナ炉の施設と産出の経路は今のゲームに無い(P06 の未決定)。霧の炉の ×1.2(120)は、マナの貯蔵の規則(受け入れの判定と `plan`)にだけ入り、通常の営業で観測できるのは世界樹の産出(20→24)だけ。
- 今の客の種類で性欲が増えるのは、成人確認済みの客(`adventurer` など)に Runtime が渡す昼 3・夜 7 だけなので、霧の効果は昼 4・夜 10 として表れる。
- 予報はその日の予定だけを出す(翌日は 06:00 に決まる)。UI は今の部品のままで、完成の見た目ではない。Windows の実機は後。
- 性能の調整はしていない。横断条件の性能の回帰は、最終の S の全 roster の既存の性能の suite の結果で見る。
- 新しい出来事 `WEATHER_FORECAST_DRAWN` が毎日 1 件増える。出来事の番号は、その分だけ後ろへずれる。
## 4. 確かめ(局所。正式の証拠ではない)
- 既存の天候の 3 本(期待値の変更は `EXPECTATION_CHANGES.json`)、関係する既存の 8 本、新しい suite(88 checks)、再開の書き手(28 checks)と読み手(47 checks、別の process)が、scratch の stage で全て合格した。正式の証拠は、提出の S の native の収集、登録の validation、最終の S の全 roster。
@@ -0,0 +1,53 @@
# P07-01-WX 実装の記録(実 Opus の実装担当、2026-10-24)
- 仕様: 同じフォルダの `SPEC.md`(main `7ac2224` に固定。Runner の `spec_sha256` は `f1cd4262…fbd783`)。出典は `PROVENANCE.json`。
- 原本への加算は 0。原 P07-01 の F1〜F8・炉 120・`AR.weather.fog_magic`・原 88 の分母は未達のまま。
- 管理の記録は `artifacts/original-completion-2026-10-02/p07-01-wx-control-r01/` にある(git の外)。
## 1. 持ち越し(SPEC §3)
- 原 task の提出 S `dfacc83` の差分 24 ファイルを、`CARRY_OVER_DFACC83.patch`(SHA-256 `a880ea89…58f9`)で W に1回だけ当てた。
- 当てた後、24 ファイル全部が manifest(`CARRY_OVER_DFACC83.json`)の bytes・SHA-256 と、S `dfacc83` の blob の両方に一致した(`CARRY_OVER_APPLY_R01.json`)。
- 1回目は sandbox が W への書き込みを拒み、何も当たらなかった(W は clean のまま)。その記録を残してから当て直した。
- 製品と試験は持ち越しのまま。変えたのは下の照合の道具だけ。
## 2. 照合の直し(`tools/run_p07_weather_tests.py` の `verify`)
- 原因:
- S `dfacc83` の native の収集では、14 本の suite が全て PASS した。その後の照合が「exact controlled Guard/proof environment」で止まった。
- 照合は `ordinary_cleanup_prepared` の suite の環境しか期待していなかった。`old_bounded_teardown` の suite(`tests/environment/test_p07_weather_runtime.gd` と `tests/ui/test_main_dashboard.gd`)には、共通の収集が `THE_TREE_AUDIO_TEARDOWN_PROOF_DIR` を渡しているのに、それを期待していなかった。
- 直し(`VERIFY_OLD_BOUNDED_FIX_DRAFT_R01.md` のとおり):
1. 段ごとの分類(ordinary・old_bounded)を、共通の収集(`tools/run_all_tests.py`)と同じ `phase_policy` から決める。
2. 期待する環境:
- ordinary の時は今のまま(`<段>-teardown/owned-audio` と `control.json`)。
- old_bounded の時は `<段>-teardown`(共通の収集と同じ)。
- どちらでもない時は無し。
3. old_bounded の suite は、共通の `teardown_gate` で証明を照合し直し、収集の記録(`old_bounded_proof`)と一致することを求める。
4. 照合を build の場所の写しにかけた時のために、`teardown_gate` が読む証明の path を読み替える。方法は ordinary の照合(`owners.read`)と同じ。照合の道具の中で作った module の写しだけを変え、共通の gate は変えていない。
5. ordinary の証明・old_bounded の証明・`<段>-teardown` のファイルが、分類された suite にだけあることを求める(import と parse の段を含む)。
- 消した検査は無い。任意の環境は許さない。
## 3. 訂正
- 持ち越した `../IMPLEMENTATION_NOTES.md` §3 の「(P06 の未決定)」は誤り。
- 炉の産出を通常の営業で示すのに要るものは、次の3つ(SPEC §6):
- P13 の炉の施設
- D04 のユニットの消費
- A06 の別承認
- 持ち越しのファイルは bytes のまま残し、訂正はここに書く。
## 4. 確かめ(局所。正式の証拠ではない)
- 直した判定を、旧 S の native の生の出力(読むだけ)に当てた(`VERIFIER_FIX_LOCAL_CHECK_R01.json`)。照合の全体は、受領が旧い道具の bytes を固定しているので、旧い出力にはかけられない。
- 16 段の環境と分類が記録と一致した。
- bounded の 2 本の証明が、元の場所と別の場所の写しの両方で記録と一致した。
- 直す前の期待は、元の失敗と同じ 000・011 の段で外れる。
- 読み替えの無い写しと、1 byte 変えた写しは拒まれる。
- 直した道具の `--prepare-only` は exit 0(engine 0)。
- 製品は S `dfacc83` と同じ bytes なので、engine の局所の再実行はしていない。
- 正式の証拠は、新しい S の native の収集、登録の validation、全 roster、独立レビュー。旧 S の結果は流用しない。
## 5. 開示
- 炉 120 と `AR.weather.fog_magic` は受け入れない(SPEC §6)。
- 他の開示は、持ち越した `../IMPLEMENTATION_NOTES.md` §2・§3(P06 の句を除く)と `../EXPECTATION_CHANGES.json` のとおり。
+16
View File
@@ -136,6 +136,7 @@ var rank_button: Button
var rank_dialog: ConfirmationDialog var rank_dialog: ConfirmationDialog
var event_log_dialog: AcceptDialog var event_log_dialog: AcceptDialog
var weather_label: Label var weather_label: Label
var weather_forecast_label: Label
var guest_count_label: Button var guest_count_label: Button
var dimension_migration_dialog: ConfirmationDialog var dimension_migration_dialog: ConfirmationDialog
var _dimension_migration_digest := "" var _dimension_migration_digest := ""
@@ -467,6 +468,11 @@ func _create_top_bar() -> Control:
weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。" weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。"
weather_label.mouse_filter = Control.MOUSE_FILTER_STOP weather_label.mouse_filter = Control.MOUSE_FILTER_STOP
row.add_child(weather_label) row.add_child(weather_label)
# A13 forecast: the next change of today's weather (kind and time).
weather_forecast_label = _hud_value_label("", 72)
weather_forecast_label.name = "WeatherForecastLabel"
weather_forecast_label.mouse_filter = Control.MOUSE_FILTER_STOP
row.add_child(weather_forecast_label)
guest_count_label = Button.new() guest_count_label = Button.new()
guest_count_label.text = "来客 0人" guest_count_label.text = "来客 0人"
@@ -1550,6 +1556,16 @@ func _refresh_dashboard() -> void:
var weather_report: Dictionary = _runtime.get_weather_report() var weather_report: Dictionary = _runtime.get_weather_report()
var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"} var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"}
weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —") weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —")
var next_change: Dictionary = weather_report.get(&"next_change", {})
var forecast := "次: 予定なし"
if not next_change.is_empty():
var change_minute := posmod(int(next_change.get(&"minute", 0)), 1_440)
forecast = "次: %s %02d:%02d" % [weather_names.get(StringName(next_change.get(&"kind", &"")), "天候 —"), change_minute / 60, change_minute % 60]
var effect := String(weather_report.get(&"effect_text", ""))
weather_label.tooltip_text = "%s\n毎朝6時にその日の天候が決まります。" % effect if not effect.is_empty() else weather_label.tooltip_text
if weather_forecast_label != null:
weather_forecast_label.text = forecast
weather_forecast_label.tooltip_text = "%s\n%s" % [forecast, effect]
var guests := _call_array(_service(&"guest_service"), &"get_guests") var guests := _call_array(_service(&"guest_service"), &"get_guests")
var queued_guests := 0 var queued_guests := 0
var served_guests := 0 var served_guests := 0
@@ -0,0 +1,68 @@
extends RefCounted
## Approved initial weather values A13 (P07-01 SPEC sections 2 and 4). The
## timeline stays a pure state model; this file owns the draw and the effects.
const DAY_MINUTES := 1440
const DRAW_MINUTE_OF_DAY := 360
const FIRST_START_MINUTE_OF_DAY := 360
const START_SLOT_MINUTES := 30
const START_SLOTS := 29
## Rolls 0..99: clear 0-59, rain 60-84, mana fog 85-94, storm 95-99.
const CLEAR_BELOW := 60
const RAIN_BELOW := 85
const FOG_BELOW := 95
const DURATION_MINUTES := {&"rain": 360, &"mana_fog": 240, &"storm": 120}
const RAIN_ARRIVAL_PERCENT := 50
const RAIN_HOLD_MAX_MINUTES := 240
const FOG_MANA_NUMERATOR := 12
const FOG_MANA_DENOMINATOR := 10
const FOG_INTIMACY_NUMERATOR := 3
const FOG_INTIMACY_DENOMINATOR := 2
static func is_draw_minute(absolute_minute: int) -> bool:
return absolute_minute >= 0 and posmod(absolute_minute, DAY_MINUTES) == DRAW_MINUTE_OF_DAY
static func day_of(absolute_minute: int) -> int:
return int(floor(float(absolute_minute) / DAY_MINUTES)) if absolute_minute >= 0 else -1
## One draw per day from the saved weather stream. A clear day consumes one
## roll; other days also consume one start-slot roll.
static func draw(rng: DeterministicRng, day: int) -> Dictionary:
var roll := rng.randi_range(0, 99)
var kind: StringName = &"clear"
if roll >= FOG_BELOW:
kind = &"storm"
elif roll >= RAIN_BELOW:
kind = &"mana_fog"
elif roll >= CLEAR_BELOW:
kind = &"rain"
if kind == &"clear":
return {&"day": day, &"kind": kind, &"roll": roll, &"slot": -1, &"start": -1, &"end": -1}
var slot := rng.randi_range(0, START_SLOTS - 1)
var start := day * DAY_MINUTES + FIRST_START_MINUTE_OF_DAY + slot * START_SLOT_MINUTES
return {&"day": day, &"kind": kind, &"roll": roll, &"slot": slot,
&"start": start, &"end": start + int(DURATION_MINUTES[kind])}
static func arrival_percent(kind: StringName) -> int:
return RAIN_ARRIVAL_PERCENT if kind == &"rain" else 100
static func mana_yield(base_amount: int, kind: StringName) -> int:
if kind != &"mana_fog":
return base_amount
return int(floor(float(base_amount * FOG_MANA_NUMERATOR) / FOG_MANA_DENOMINATOR))
static func fog_intimacy_increase(increase: int) -> int:
if increase <= 0:
return increase
return int(floor(float(increase * FOG_INTIMACY_NUMERATOR) / FOG_INTIMACY_DENOMINATOR))
## Japanese HUD guidance for the current kind (SPEC section 4.6).
static func effect_text(kind: StringName) -> String:
match kind:
&"rain":
return "雨: 新規来客が半分になり、帰る予定の客は雨が止むまで(最大4時間)残ります。"
&"storm":
return "嵐: 新規来客はありません。館内の客は残ります。"
&"mana_fog":
return "マナ霧: 世界樹のマナ産出が1.2倍、成人確認済みの客の性欲の増え方が1.5倍になります。"
return "晴れ: 天候の影響はありません。"
@@ -0,0 +1 @@
uid://bkr55x5to4cgi
+164 -4
View File
@@ -2,14 +2,25 @@ extends RefCounted
## Weather state and scheduled transitions. The caller owns generation policy; ## Weather state and scheduled transitions. The caller owns generation policy;
## this model invents neither probabilities nor rain/fog balance multipliers. ## this model invents neither probabilities nor rain/fog balance multipliers.
const VERSION := 1 ## Version 2 also keeps the natural-weather bookkeeping (last daily draw, the
## current day's draw) and the once-per-guest rain holds; version 1 snapshots
## migrate with no draw for the past and no holds (P07-01 SPEC section 5).
const VERSION := 2
const LEGACY_VERSION := 1
const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"] const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"]
const MAX_MINUTE := 2147483647 const MAX_MINUTE := 2147483647
const MAX_PENDING := 4096 const MAX_PENDING := 4096
const MAX_RAIN_HOLDS := 100000
const DAY_MINUTES := 1440
const DRAW_MINUTE_OF_DAY := 360
const RAIN_HOLD_MAX_MINUTES := 240
var _minute := -1 var _minute := -1
var _kind: StringName = &"" var _kind: StringName = &""
var _since := -1 var _since := -1
var _pending: Array[Dictionary] = [] var _pending: Array[Dictionary] = []
var _last_draw_day := -1
var _today_draw: Dictionary = {}
var _rain_holds: Dictionary = {}
func initialize(kind: Variant, minute: Variant) -> bool: func initialize(kind: Variant, minute: Variant) -> bool:
if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute): if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute):
@@ -17,6 +28,7 @@ func initialize(kind: Variant, minute: Variant) -> bool:
_minute = minute _minute = minute
_since = minute _since = minute
_kind = StringName(kind) _kind = StringName(kind)
_last_draw_day = _days_before(minute)
return true return true
func get_kind() -> StringName: func get_kind() -> StringName:
@@ -25,6 +37,9 @@ func get_kind() -> StringName:
func get_minute() -> int: func get_minute() -> int:
return _minute return _minute
func get_since() -> int:
return _since
func permits_new_arrivals() -> bool: func permits_new_arrivals() -> bool:
return _minute >= 0 and _kind != &"storm" return _minute >= 0 and _kind != &"storm"
@@ -55,13 +70,95 @@ func advance_to(minute: Variant) -> Dictionary:
_minute = minute _minute = minute
return {&"ok": true, &"replayed": replayed, &"changes": changes} return {&"ok": true, &"replayed": replayed, &"changes": changes}
## Natural weather (A13): the caller draws once per day at 06:00 and applies
## the result here. A start at the current minute switches now; later starts
## and the return to clear use the ordinary schedule rules, applied together.
func get_last_draw_day() -> int:
return _last_draw_day
func get_today_draw() -> Dictionary:
return _today_draw.duplicate(true)
func apply_draw(draw: Dictionary) -> Dictionary:
if _minute < 0 or not _valid_draw(draw):
return {&"ok": false, &"code": &"INVALID_DRAW"}
var day: int = draw[&"day"]
if day <= _last_draw_day or _minute != day * DAY_MINUTES + DRAW_MINUTE_OF_DAY:
return {&"ok": false, &"code": &"DRAW_NOT_DUE"}
var kind := StringName(draw[&"kind"])
var start: int = draw[&"start"]
var end: int = draw[&"end"]
var changes: Array[Dictionary] = []
if kind != &"clear":
for row: Dictionary in _pending:
if int(row[&"minute"]) == start or int(row[&"minute"]) == end:
return {&"ok": false, &"code": &"SCHEDULE_CONFLICT"}
if _pending.size() + 2 > MAX_PENDING:
return {&"ok": false, &"code": &"SCHEDULE_FULL"}
if start == _minute:
if kind != _kind:
changes.append({&"from": _kind, &"to": kind, &"minute": start})
_kind = kind
_since = start
else:
_pending.append({&"kind": kind, &"minute": start})
_pending.append({&"kind": &"clear", &"minute": end})
_pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"])
_last_draw_day = day
_today_draw = _copy_draw(draw)
return {&"ok": true, &"changes": changes}
## The next scheduled change away from the current kind, for the forecast.
func next_change() -> Dictionary:
for row: Dictionary in _pending:
if row[&"kind"] != _kind:
return {&"kind": row[&"kind"], &"minute": row[&"minute"]}
return {}
## When the current rain ends (-1 when it is not raining or no end is known).
func rain_end_minute() -> int:
if _kind != &"rain":
return -1
for row: Dictionary in _pending:
if row[&"kind"] != &"rain":
return int(row[&"minute"])
return -1
func get_rain_hold(guest_id: StringName) -> Dictionary:
var row: Variant = _rain_holds.get(String(guest_id), null)
return row.duplicate(true) if row is Dictionary else {}
func get_rain_holds() -> Dictionary:
return _rain_holds.duplicate(true)
func add_rain_hold(guest_id: StringName, original_exit: int, extended_to: int) -> bool:
var key := String(guest_id)
if key.is_empty() or _rain_holds.has(key) or _rain_holds.size() >= MAX_RAIN_HOLDS \
or not _valid_hold({"original_exit": original_exit, "extended_to": extended_to}):
return false
_rain_holds[key] = {"original_exit": original_exit, "extended_to": extended_to}
return true
## Holds of guests that have left are history; the caller passes who remains.
func prune_rain_holds(present_guest_ids: Dictionary) -> void:
for key: String in _rain_holds.keys():
if not present_guest_ids.has(StringName(key)):
_rain_holds.erase(key)
func create_snapshot() -> Dictionary: func create_snapshot() -> Dictionary:
return {&"version": VERSION, &"minute": _minute, &"kind": _kind, return {&"version": VERSION, &"minute": _minute, &"kind": _kind,
&"since": _since, &"pending": _pending.duplicate(true)} &"since": _since, &"pending": _pending.duplicate(true),
&"natural": {&"last_draw_day": _last_draw_day, &"today": _today_draw.duplicate(true)},
&"rain_holds": _rain_holds.duplicate(true)}
func restore_snapshot(source: Dictionary) -> bool: func restore_snapshot(source: Dictionary) -> bool:
if not _keys(source, ["version", "minute", "kind", "since", "pending"]) \ var version: Variant = source.get("version", source.get(&"version", null))
or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \ if typeof(version) != TYPE_INT or version not in [LEGACY_VERSION, VERSION]:
return false
var expected := ["version", "minute", "kind", "since", "pending"]
if version == VERSION:
expected = expected + ["natural", "rain_holds"]
if not _keys(source, expected) \
or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \ or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \
or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \ or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \
or not source["pending"] is Array or source["pending"].size() > MAX_PENDING: or not source["pending"] is Array or source["pending"].size() > MAX_PENDING:
@@ -75,12 +172,75 @@ func restore_snapshot(source: Dictionary) -> bool:
return false return false
previous = value["minute"] previous = value["minute"]
pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]}) pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]})
var last_draw_day := _days_before(source["minute"])
var today: Dictionary = {}
var holds: Dictionary = {}
if version == VERSION:
var natural: Variant = source["natural"]
if not natural is Dictionary or not _keys(natural, ["last_draw_day", "today"]) \
or typeof(natural["last_draw_day"]) != TYPE_INT or natural["last_draw_day"] < -1 \
or natural["last_draw_day"] > _day_of(source["minute"]) or not natural["today"] is Dictionary:
return false
last_draw_day = natural["last_draw_day"]
if not natural["today"].is_empty():
if not _valid_draw(natural["today"]) or int(natural["today"]["day"]) != last_draw_day:
return false
today = _copy_draw(natural["today"])
var raw_holds: Variant = source["rain_holds"]
if not raw_holds is Dictionary or raw_holds.size() > MAX_RAIN_HOLDS:
return false
for key: Variant in raw_holds:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key).is_empty() \
or not raw_holds[key] is Dictionary or not _keys(raw_holds[key], ["original_exit", "extended_to"]) \
or not _valid_hold(raw_holds[key]):
return false
holds[String(key)] = {"original_exit": int(raw_holds[key]["original_exit"]),
"extended_to": int(raw_holds[key]["extended_to"])}
_minute = source["minute"] _minute = source["minute"]
_kind = StringName(source["kind"]) _kind = StringName(source["kind"])
_since = source["since"] _since = source["since"]
_pending = pending _pending = pending
_last_draw_day = last_draw_day
_today_draw = today
_rain_holds = holds
return true return true
## A day whose 06:00 is before the minute counts as drawn, so a new or migrated
## timeline never draws for the past (SPEC section 5.2). At exactly 06:00 the
## day is still due, which lets a new game draw its opening day.
static func _days_before(minute: int) -> int:
var day := _day_of(minute)
return day if posmod(minute, DAY_MINUTES) > DRAW_MINUTE_OF_DAY else day - 1
static func _day_of(minute: int) -> int:
return int(floor(float(minute) / DAY_MINUTES))
static func _valid_draw(draw: Variant) -> bool:
if not draw is Dictionary or not _keys(draw, ["day", "kind", "roll", "slot", "start", "end"]):
return false
for key: String in ["day", "roll", "slot", "start", "end"]:
if typeof(draw[key]) != TYPE_INT:
return false
if not _valid_kind(draw["kind"]) or draw["day"] < 0 or draw["roll"] < 0 or draw["roll"] > 99:
return false
if StringName(draw["kind"]) == &"clear":
return draw["slot"] == -1 and draw["start"] == -1 and draw["end"] == -1
var day_start: int = draw["day"] * DAY_MINUTES + DRAW_MINUTE_OF_DAY
return draw["slot"] >= 0 and draw["start"] >= day_start and draw["start"] <= day_start + 14 * 60 \
and draw["end"] > draw["start"] and draw["end"] < day_start + DAY_MINUTES
static func _copy_draw(draw: Dictionary) -> Dictionary:
var result := {}
for key: String in ["day", "kind", "roll", "slot", "start", "end"]:
result[key] = draw[key] if key != "kind" else String(draw[key])
return result
static func _valid_hold(row: Dictionary) -> bool:
var original: Variant = row.get("original_exit", null)
var extended: Variant = row.get("extended_to", null)
return typeof(original) == TYPE_INT and typeof(extended) == TYPE_INT and original >= 0 \
and extended > original and extended <= original + RAIN_HOLD_MAX_MINUTES
static func _valid_kind(value: Variant) -> bool: static func _valid_kind(value: Variant) -> bool:
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS
+5 -1
View File
@@ -365,6 +365,10 @@ func _update_needs_runtime(
delta_medical += int(normalized_effects.get(&"medical", 0)) delta_medical += int(normalized_effects.get(&"medical", 0))
delta_leisure += int(normalized_effects.get(&"leisure", 0)) delta_leisure += int(normalized_effects.get(&"leisure", 0))
delta_intimacy += int(normalized_effects.get(&"intimacy", 0)) delta_intimacy += int(normalized_effects.get(&"intimacy", 0))
## A13: mana fog raises an adult-verified guest's positive intimacy increase
## to floor(x1.5) = n + floor(n / 2); decreases and other guests are unchanged.
if definition.adult_verified and delta_intimacy > 0 and bool(_value(context, &"mana_fog", false)):
delta_intimacy += delta_intimacy >> 1
var after_hunger := clampi(before_hunger + delta_hunger, 0, 100) var after_hunger := clampi(before_hunger + delta_hunger, 0, 100)
var after_sleep := clampi(before_sleep + delta_sleep, 0, 100) var after_sleep := clampi(before_sleep + delta_sleep, 0, 100)
@@ -1157,7 +1161,7 @@ func _need_context_error(context: Dictionary) -> String:
var raw_value: Variant = _value(context, field_id, null) var raw_value: Variant = _value(context, field_id, null)
if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100: if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100:
return "%s must be an integer from 0 through 100." % field_id return "%s must be an integer from 0 through 100." % field_id
for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated"]: for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated", &"mana_fog"]:
if _has_value(context, field_id) and not _value(context, field_id, null) is bool: if _has_value(context, field_id) and not _value(context, field_id, null) is bool:
return "%s must be boolean." % field_id return "%s must be boolean." % field_id
if _has_value(context, &"need_effects"): if _has_value(context, &"need_effects"):
+20 -13
View File
@@ -11,6 +11,10 @@ const CAPACITY := 300
const WORLD_TREE_GENERATION := 20 const WORLD_TREE_GENERATION := 20
const INTERVAL_MINUTES := 10 const INTERVAL_MINUTES := 10
const FURNACE_YIELD := 100 const FURNACE_YIELD := 100
## A13 mana fog: world tree and furnace yields x1.2 (floor) at fog boundaries.
const FOG_WORLD_TREE_GENERATION := 24
const FOG_FURNACE_YIELD := 120
const GENERATIONS: Array[int] = [WORLD_TREE_GENERATION, FOG_WORLD_TREE_GENERATION]
const LIMIT := 1000000000 const LIMIT := 1000000000
const FLOW_KEYS: Array[String] = [ const FLOW_KEYS: Array[String] = [
"from_minute", "to_minute", "before", "generation", "furnace", "demand", "from_minute", "to_minute", "before", "generation", "furnace", "demand",
@@ -35,12 +39,14 @@ func get_last_flow() -> Dictionary:
## Processes every boundary in (last boundary, minute]. Boundaries already ## Processes every boundary in (last boundary, minute]. Boundaries already
## processed are no-ops, so per-minute callers and same-minute retries are safe. ## processed are no-ops, so per-minute callers and same-minute retries are safe.
## Mana may rise between boundaries (event refunds, commands); anything above ## Mana may rise between boundaries (event refunds, commands); anything above
## capacity is discarded and recorded as overflow at the next boundary. ## capacity is discarded and recorded as overflow at the next boundary. The
func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary: ## caller passes the world tree yield of the boundary (A13 fog: 24, else 20).
func advance_to(minute: Variant, before: Variant, demand: Variant, generation: Variant = WORLD_TREE_GENERATION) -> Dictionary:
if _minute < 0: if _minute < 0:
return _failure(&"NOT_STARTED") return _failure(&"NOT_STARTED")
if typeof(minute) != TYPE_INT or not _amount(before) \ if typeof(minute) != TYPE_INT or not _amount(before) \
or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute: or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute \
or typeof(generation) != TYPE_INT or generation not in GENERATIONS:
return _failure(&"INVALID_FLOW") return _failure(&"INVALID_FLOW")
var boundary: int = minute - minute % INTERVAL_MINUTES var boundary: int = minute - minute % INTERVAL_MINUTES
if boundary <= _minute: if boundary <= _minute:
@@ -49,7 +55,7 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary
var flow := {} var flow := {}
var count := 0 var count := 0
for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES): for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES):
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute) flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute, generation)
amount = flow[&"after"] amount = flow[&"after"]
count += 1 count += 1
_minute = boundary _minute = boundary
@@ -57,17 +63,18 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary
return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)} return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)}
## Furnace offers are judged after natural generation and upkeep demand are ## 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. ## paid: base_after = min(C, max(0, B + G - D)); accept only with the yield free
static func furnace_offer_accepted(before: int, demand: int) -> bool: ## (100, or 120 at an A13 fog boundary where G is 24).
var base_after := mini(CAPACITY, maxi(0, before + WORLD_TREE_GENERATION - demand)) static func furnace_offer_accepted(before: int, demand: int, generation: int = WORLD_TREE_GENERATION, furnace_yield: int = FURNACE_YIELD) -> bool:
return CAPACITY - base_after >= FURNACE_YIELD var base_after := mini(CAPACITY, maxi(0, before + generation - demand))
return CAPACITY - base_after >= furnace_yield
static func plan(before: int, demand: int, furnace: int, start: int, end: int) -> Dictionary: static func plan(before: int, demand: int, furnace: int, start: int, end: int, generation: int = WORLD_TREE_GENERATION) -> Dictionary:
var available := before + WORLD_TREE_GENERATION + furnace var available := before + generation + furnace
var consumed := mini(available, demand) var consumed := mini(available, demand)
var remaining := available - consumed var remaining := available - consumed
return {&"from_minute": start, &"to_minute": end, &"before": before, return {&"from_minute": start, &"to_minute": end, &"before": before,
&"generation": WORLD_TREE_GENERATION, &"furnace": furnace, &"demand": demand, &"generation": generation, &"furnace": furnace, &"demand": demand,
&"consumed": consumed, &"shortage": demand - consumed, &"consumed": consumed, &"shortage": demand - consumed,
&"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)} &"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)}
@@ -105,10 +112,10 @@ func restore_snapshot(source: Variant) -> bool:
if not _amount(flow[key]): if not _amount(flow[key]):
return false return false
if flow["to_minute"] != source["minute"] \ if flow["to_minute"] != source["minute"] \
or flow["generation"] != WORLD_TREE_GENERATION \ or flow["generation"] not in GENERATIONS \
or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]: or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]:
return false return false
var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"]) var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"], flow["generation"])
if not _same_flow(expected, flow): if not _same_flow(expected, flow):
return false return false
_minute = source["minute"] _minute = source["minute"]
+26 -1
View File
@@ -1013,6 +1013,15 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
&"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID" &"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID"
) )
var external_capacity := int(capacity_value) var external_capacity := int(capacity_value)
# A13: the arrival boundary reports the weather share of planned arrivals
# (rain 50, otherwise 100). Absent means 100 for callers without weather.
var percent_value: Variant = capture.get(&"arrival_percent", 100)
if not _is_integral_value(percent_value) or int(percent_value) < 0 or int(percent_value) > 100:
return _failed_boundary_after_capture(
_arrival_boundary, capture_payload, token,
&"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID"
)
var arrival_percent := int(percent_value)
var remaining := mini( var remaining := mini(
mini(local_capacity, external_capacity), mini(local_capacity, external_capacity),
int(_config[&"max_arrivals_per_interval"]) int(_config[&"max_arrivals_per_interval"])
@@ -1022,6 +1031,8 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
var plans: Array[Dictionary] = [] var plans: Array[Dictionary] = []
var total_count := 0 var total_count := 0
var capped_rows: Array[Dictionary] = [] var capped_rows: Array[Dictionary] = []
var accepted_by_row: Array[int] = []
var planned_total := 0
for row: Dictionary in intent_rows: for row: Dictionary in intent_rows:
var accepted := mini(int(row[&"desired_count"]), remaining) var accepted := mini(int(row[&"desired_count"]), remaining)
var key: String = row[&"key"] var key: String = row[&"key"]
@@ -1034,6 +1045,21 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
&"guest_class": String(row[&"guest_class"]), &"guest_class": String(row[&"guest_class"]),
&"discarded_numerator": retained - capped_retained, &"discarded_numerator": retained - capped_retained,
}) })
accepted_by_row.append(accepted)
planned_total += accepted
remaining -= accepted
# Rain keeps floor(planned * percent / 100) arrivals; the dropped arrivals
# are lost to the weather and are not carried to the next boundary.
var dropped := planned_total - planned_total * arrival_percent / 100
var drop_index := accepted_by_row.size() - 1
while dropped > 0 and drop_index >= 0:
var cut := mini(dropped, accepted_by_row[drop_index])
accepted_by_row[drop_index] -= cut
dropped -= cut
drop_index -= 1
for row_index in intent_rows.size():
var row: Dictionary = intent_rows[row_index]
var accepted := accepted_by_row[row_index]
if accepted <= 0: if accepted <= 0:
continue continue
if plan_sequence > MAX_SEQUENCE_VALUE \ if plan_sequence > MAX_SEQUENCE_VALUE \
@@ -1058,7 +1084,6 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
}) })
plan_sequence += 1 plan_sequence += 1
total_count += accepted total_count += accepted
remaining -= accepted
var payload := capture_payload.duplicate(true) var payload := capture_payload.duplicate(true)
payload[&"plans"] = plans payload[&"plans"] = plans
@@ -0,0 +1,86 @@
extends SceneTree
## P07-01 F7, writer phase. Plays the minute script, writes ordinary save files
## mid-rain, just before and after the rain end and just before and after the
## day 1 draw, and hands the exact save packages to the reader suite
## (test_p07_weather_restart_2_read.gd), which runs as a separate engine process.
## The handoff is the declared generated report res://artifacts/p07-01/restart-handoff.json
## (tools/regression/suite-policy.json fixed_generated_report_routes).
const Fixture := preload("res://tests/helpers/p07_weather_restart_fixture.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("P07-01: restart writer")
var fixtures := Fixture.new(_expect)
var straight := fixtures.new_fixture()
var straight_runtime: Variant = straight[&"runtime"]
fixtures.play(straight, Fixture.FIRST_MINUTE, Fixture.LAST_MINUTE, Callable())
_expect(straight_runtime.weather.get_rain_hold(Fixture.HELD_GUEST) == {"original_exit": 362, "extended_to": 602} or straight_runtime.guest_service.get_guest(Fixture.HELD_GUEST) == null,
"the guest due at 06:02 was held to 10:02 (or has left since)")
_expect(straight[&"draws"][&"count"] == Fixture.DRAW_MINUTES.size() and straight_runtime.weather.get_last_draw_day() == 1, "the uninterrupted script draws day 1 once")
var saving := fixtures.new_fixture()
var saving_runtime: Variant = saving[&"runtime"]
var saved_states := {}
var saved_paths := {}
fixtures.play(saving, Fixture.FIRST_MINUTE, Fixture.LAST_MINUTE, func(minute: int) -> void:
if minute in Fixture.SAVE_MINUTES:
if minute == 500:
var held_present: bool = saving_runtime.guest_service.get_guest(Fixture.HELD_GUEST) != null
_expect(saving_runtime.weather.get_kind() == &"rain" and (not held_present or not saving_runtime.weather.get_rain_hold(Fixture.HELD_GUEST).is_empty()), "minute 500 saves mid-rain with the hold of a present guest")
var path := Fixture.WRITER_SAVE % minute
saved_states[minute] = fixtures.state_bytes(saving_runtime)
_expect(bool(saving_runtime.save_to_path(path).get(&"ok", false)), "save file at minute %d" % minute)
saved_paths[minute] = path)
_expect(fixtures.state_bytes(saving_runtime) == fixtures.state_bytes(straight_runtime), "writing the save files leaves the weather state as without saving")
var saves: Array = []
for minute: int in Fixture.SAVE_MINUTES:
var directory := ProjectSettings.globalize_path(str(saved_paths.get(minute, Fixture.WRITER_SAVE % minute)))
_expect(DirAccess.dir_exists_absolute(directory) and DirAccess.get_directories_at(directory).is_empty(), "save package at minute %d holds only files" % minute)
var files := {}
for name: String in DirAccess.get_files_at(directory):
var bytes := FileAccess.get_file_as_bytes(directory.path_join(name))
files[name] = {"sha256": Fixture.sha256(bytes), "bytes": bytes.size(), "base64": Marshalls.raw_to_base64(bytes)}
_expect(files.has("manifest.json") and files.has("world.bin"), "save package at minute %d has its manifest and world" % minute)
var state: PackedByteArray = saved_states.get(minute, PackedByteArray())
saves.append({"minute": minute, "files": files, "state_sha256": Fixture.sha256(state), "state_base64": Marshalls.raw_to_base64(state)})
var final_state := fixtures.state_bytes(straight_runtime)
var handoff := {
"schema": Fixture.HANDOFF_SCHEMA,
"writer_pid": OS.get_process_id(),
"writer_source_sha256": FileAccess.get_sha256(Fixture.WRITER_SOURCE),
"seed": Fixture.SEED,
"last_minute": Fixture.LAST_MINUTE,
"straight_final_state_sha256": Fixture.sha256(final_state),
"straight_final_state_base64": Marshalls.raw_to_base64(final_state),
"saves": saves,
}
DirAccess.make_dir_recursive_absolute(Fixture.HANDOFF_PATH.get_base_dir())
var file := FileAccess.open(Fixture.HANDOFF_PATH, FileAccess.WRITE)
_expect(file != null, "handoff file opens for writing")
if file != null:
file.store_string(JSON.stringify(handoff, "\t") + "\n")
file.close()
print("P07_RESTART_WRITER pid=%d handoff_sha256=%s" % [OS.get_process_id(), FileAccess.get_sha256(Fixture.HANDOFF_PATH)])
fixtures.free_fixture(straight)
fixtures.free_fixture(saving)
await process_frame
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _expect(condition: bool, message: String) -> void:
_checks += 1
if not condition:
_failures += 1
push_error(message)
@@ -0,0 +1 @@
uid://bz9x67zdqdrc6
@@ -0,0 +1,92 @@
extends SceneTree
## P07-01 F7, reader phase (a separate engine process after the writer). Each
## handed-off save package is loaded into a fresh Runtime, must equal the writer's
## state at that minute, and must continue to the writer's uninterrupted final
## state, with every remaining 06:00 draw happening exactly once.
const Fixture := preload("res://tests/helpers/p07_weather_restart_fixture.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("P07-01: restart reader")
var handoff := _read_handoff()
if not handoff.is_empty():
_check_restarts(handoff)
await process_frame
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _read_handoff() -> Dictionary:
_expect(FileAccess.file_exists(Fixture.HANDOFF_PATH), "the writer phase left its handoff (run test_p07_weather_restart_1_write.gd first)")
if not FileAccess.file_exists(Fixture.HANDOFF_PATH):
return {}
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(Fixture.HANDOFF_PATH))
_expect(parsed is Dictionary and str((parsed as Dictionary).get("schema", "")) == Fixture.HANDOFF_SCHEMA, "handoff schema")
if not parsed is Dictionary:
return {}
var handoff: Dictionary = parsed
var writer_pid := int(handoff.get("writer_pid", 0))
_expect(writer_pid > 0 and writer_pid != OS.get_process_id(), "the writer ran in another engine process")
_expect(str(handoff.get("writer_source_sha256", "")) == FileAccess.get_sha256(Fixture.WRITER_SOURCE), "the handoff comes from the current writer suite")
_expect(int(handoff.get("seed", -1)) == Fixture.SEED and int(handoff.get("last_minute", -1)) == Fixture.LAST_MINUTE, "same fixture and minute script")
print("P07_RESTART_READER pid=%d writer_pid=%d handoff_sha256=%s" % [OS.get_process_id(), writer_pid, FileAccess.get_sha256(Fixture.HANDOFF_PATH)])
return handoff
func _check_restarts(handoff: Dictionary) -> void:
var fixtures := Fixture.new(_expect)
var final_state := Marshalls.base64_to_raw(str(handoff.get("straight_final_state_base64", "")))
_expect(Fixture.sha256(final_state) == str(handoff.get("straight_final_state_sha256", "")), "the writer's final state arrived intact")
var saves: Array = handoff.get("saves", [])
_expect(saves.size() == Fixture.SAVE_MINUTES.size(), "every save package was handed off")
for save: Variant in saves:
var minute := int((save as Dictionary).get("minute", -1))
var path := Fixture.READER_SAVE % minute
var directory := ProjectSettings.globalize_path(path)
DirAccess.make_dir_recursive_absolute(directory)
var files: Dictionary = (save as Dictionary).get("files", {})
var intact := not files.is_empty()
for name: String in files:
var bytes := Marshalls.base64_to_raw(str(files[name].get("base64", "")))
intact = intact and Fixture.sha256(bytes) == str(files[name].get("sha256", "")) and bytes.size() == int(files[name].get("bytes", -1))
var file := FileAccess.open(directory.path_join(name), FileAccess.WRITE)
if file != null:
file.store_buffer(bytes)
file.close()
_expect(intact, "save package at minute %d arrived byte-exact" % minute)
var expected_state := Marshalls.base64_to_raw(str((save as Dictionary).get("state_base64", "")))
var fixture := fixtures.new_fixture(false)
var runtime: Variant = fixture[&"runtime"]
_expect(bool(runtime.load_from_path(path).get(&"ok", false)), "the reader loads the minute %d save" % minute)
_expect(fixtures.state_bytes(runtime) == expected_state and fixture[&"clock"].absolute_minute == minute, "the minute %d save resumes the writer's weather, holds, stream and plans" % minute)
# Loading replaces the event stream, so the draw counter subscribes again.
fixture[&"draws"][&"count"] = 0
fixtures.watch_draws(fixture)
fixtures.play(fixture, minute, Fixture.LAST_MINUTE, Callable())
var expected_draws := 0
for draw_minute: int in Fixture.DRAW_MINUTES:
if draw_minute > minute:
expected_draws += 1
_expect(fixture[&"draws"][&"count"] == expected_draws, "after the minute %d save the remaining 06:00 draws happen once (%d)" % [minute, expected_draws])
_expect(fixtures.state_bytes(runtime) == final_state, "continuing from minute %d reaches the uninterrupted final state" % minute)
fixtures.free_fixture(fixture)
SaveService.delete_save(path)
func _expect(condition: bool, message: String) -> void:
_checks += 1
if not condition:
_failures += 1
push_error(message)
@@ -0,0 +1 @@
uid://bni9w8axk6f97
@@ -0,0 +1,351 @@
extends SceneTree
## P07-01 (rain, mana fog and natural weather) F1-F7 on the real Runtime with the
## approved initial values A13 (SPEC sections 4 and 5). F6 reads the ordinary Main
## HUD. The seeds open day 0 with clear (1000), rain (1106), mana fog (1592) and
## storm (2501) starting at 06:00; the draws are asserted, not assumed.
const NaturalWeather := preload("res://scripts/environment/natural_weather_policy.gd")
const WeatherTimeline := preload("res://scripts/environment/weather_timeline.gd")
const ManaReserveCycle := preload("res://scripts/mana/mana_reserve_cycle.gd")
const AudioTeardown := preload("res://tests/helpers/ui_audio_teardown.gd")
const SEED_CLEAR := 1000
const SEED_RAIN := 1106
const SEED_FOG := 1592
const SEED_STORM := 2501
var _checks := 0
var _failures := 0
var _fixtures: Array[Dictionary] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("P07-01: natural weather runtime")
_test_policy_draws()
_test_daily_natural_draw()
_test_rain_arrivals()
_test_rain_stay_and_saves()
_test_storm()
_test_mana_fog()
_test_migration_and_rejection()
await _test_hud_forecast()
_release_fixtures()
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _new_runtime(seed: int, hour := 6, minute := 0) -> Dictionary:
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
var clock: Variant = load("res://autoload/game_clock.gd").new()
var state: Variant = load("res://autoload/game_state.gd").new()
clock.reset(1, hour, minute)
state.reset()
_expect(runtime.initialize(clock, state, seed)[&"valid"], "initialize Runtime with seed %d" % seed)
clock.set_paused(true)
var fixture := {&"runtime": runtime, &"clock": clock, &"state": state}
_fixtures.append(fixture)
return fixture
func _release_fixtures() -> void:
for fixture: Dictionary in _fixtures:
for key: StringName in [&"runtime", &"clock", &"state"]:
if is_instance_valid(fixture[key]):
fixture[key].free()
_fixtures.clear()
func _drawn_events(runtime: Variant) -> Array:
var found: Array = []
for event: SimulationDomainEvent in runtime.domain_events.recent_events():
if event.event_type == &"WEATHER_FORECAST_DRAWN":
found.append(event.payload.duplicate(true))
return found
func _spawn(runtime: Variant, guest_id: StringName, guest_type: StringName, planned_exit_at: int) -> Variant:
var result: CommandResult = runtime.submit_command(&"spawn_guest", {
&"guest_id": guest_id,
&"guest_type_id": guest_type,
&"gold": 100,
&"options": {
&"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT),
&"needs": {&"hunger": 10, &"sleep": 10, &"hygiene": 10, &"medical": 0, &"leisure": 10, &"intimacy": 0},
&"planned_exit_at": planned_exit_at,
},
}, &"world", guest_id, 10, -1, StringName("p07." + String(guest_id)))
_expect(result.is_success(), "spawn %s" % guest_id)
return runtime.guest_service.get_guest(guest_id)
## F1: the A13 draw rule, determinism and the once-per-day timeline rule.
func _test_policy_draws() -> void:
var rng := DeterministicRng.from_root_seed(77, &"weather") as DeterministicRng
var counts := {&"clear": 0, &"rain": 0, &"mana_fog": 0, &"storm": 0}
var rule_ok := true
for day in range(2000):
var draw: Dictionary = NaturalWeather.draw(rng, day)
var kind: StringName = draw[&"kind"]
counts[kind] += 1
var roll: int = draw[&"roll"]
var expected_kind: StringName = &"clear" if roll < 60 else (&"rain" if roll < 85 else (&"mana_fog" if roll < 95 else &"storm"))
rule_ok = rule_ok and kind == expected_kind and roll >= 0 and roll <= 99
if kind == &"clear":
rule_ok = rule_ok and draw[&"slot"] == -1 and draw[&"start"] == -1 and draw[&"end"] == -1
else:
var offset: int = int(draw[&"start"]) - day * 1440 - 360
rule_ok = rule_ok and offset >= 0 and offset <= 14 * 60 and offset % 30 == 0 and offset / 30 == int(draw[&"slot"]) \
and int(draw[&"end"]) - int(draw[&"start"]) == int(NaturalWeather.DURATION_MINUTES[kind])
_expect(rule_ok, "every draw maps rolls 0-59/60-84/85-94/95-99 to clear/rain/fog/storm, starts 06:00-20:00 in 30-minute steps and lasts 6/4/2 hours")
_expect(counts[&"clear"] > 1100 and counts[&"clear"] < 1300 and counts[&"rain"] > 400 and counts[&"rain"] < 600
and counts[&"mana_fog"] > 140 and counts[&"mana_fog"] < 260 and counts[&"storm"] > 60 and counts[&"storm"] < 140,
"2000 draws follow 60/25/10/5 percent (%s)" % str(counts))
var left := DeterministicRng.from_root_seed(5, &"weather") as DeterministicRng
var right := DeterministicRng.from_root_seed(5, &"weather") as DeterministicRng
var same := true
for day in range(60):
same = same and NaturalWeather.draw(left, day) == NaturalWeather.draw(right, day)
_expect(same, "the same seed draws the same weather list")
var timeline := WeatherTimeline.new()
_expect(timeline.initialize(&"clear", 360) and timeline.get_last_draw_day() == -1, "a timeline opened at 06:00 still owes that day's draw")
var rain := {&"day": 0, &"kind": &"rain", &"roll": 70, &"slot": 2, &"start": 420, &"end": 780}
_expect(timeline.apply_draw(rain)[&"ok"] and timeline.get_last_draw_day() == 0 and timeline.next_change() == {&"kind": &"rain", &"minute": 420}, "a drawn rain is scheduled with its return to clear")
_expect(timeline.apply_draw(rain)[&"code"] == &"DRAW_NOT_DUE", "a second draw for the same day is refused")
var later := WeatherTimeline.new()
later.initialize(&"clear", 361)
_expect(later.apply_draw(rain)[&"code"] == &"DRAW_NOT_DUE", "a draw outside its 06:00 is refused")
## F1: one draw at 06:00 on the saved stream, recorded as WEATHER_FORECAST_DRAWN.
func _test_daily_natural_draw() -> void:
var opening := _new_runtime(SEED_RAIN)
var runtime: Variant = opening[&"runtime"]
var today: Dictionary = runtime.weather.get_today_draw()
_expect(String(today.get("kind", "")) == "rain" and int(today.get("start", -1)) == 360 and int(today.get("end", -1)) == 720, "seed 1106 opens day 0 with rain 06:00-12:00 (%s)" % str(today))
_expect(runtime.weather.get_kind() == &"rain" and runtime.weather.get_last_draw_day() == 0, "a start at the draw minute switches at once")
var events := _drawn_events(runtime)
_expect(events.size() == 1 and int(events[0][&"day"]) == 0 and String(events[0][&"kind"]) == "rain" and bool(events[0][&"applied"]), "the opening draw is recorded once")
runtime.call("_draw_natural_weather_if_due", 360)
_expect(_drawn_events(runtime).size() == 1, "the same minute does not draw again")
var twin: Variant = _new_runtime(SEED_RAIN)[&"runtime"]
_expect(twin.weather.create_snapshot() == runtime.weather.create_snapshot()
and twin.get_rng_stream(&"weather").snapshot() == runtime.get_rng_stream(&"weather").snapshot(), "the same seed gives the same weather and stream")
var early := _new_runtime(SEED_RAIN, 5, 50)
var early_runtime: Variant = early[&"runtime"]
_expect(early_runtime.weather.get_today_draw().is_empty() and _drawn_events(early_runtime).is_empty(), "a game opened before 06:00 waits for the boundary")
early[&"clock"].advance_minutes(10)
_expect(early_runtime.weather.get_today_draw() == today and early_runtime.weather.get_kind() == &"rain", "the 06:00 boundary draws the same day from the same stream")
var daily := _new_runtime(SEED_CLEAR)
var daily_runtime: Variant = daily[&"runtime"]
_expect(String(daily_runtime.weather.get_today_draw().get("kind", "")) == "clear" and daily_runtime.weather.get_kind() == &"clear", "seed 1000 opens day 0 clear")
var seen := {&"count": 0}
daily_runtime.domain_events.subscribe(&"WEATHER_FORECAST_DRAWN", &"p07_test", func(_event: SimulationDomainEvent) -> void: seen[&"count"] += 1)
daily[&"clock"].advance_minutes(1439)
_expect(seen[&"count"] == 0 and daily_runtime.weather.get_last_draw_day() == 0, "no draw before the next 06:00")
daily[&"clock"].advance_minutes(1)
_expect(seen[&"count"] == 1 and daily_runtime.weather.get_last_draw_day() == 1 and int(daily_runtime.weather.get_today_draw().get("day", -1)) == 1, "the next 06:00 draws day 1 once")
## F2: rain halves the planned new arrivals of a boundary; the dropped ones are not carried.
func _test_rain_arrivals() -> void:
var clear_side := _new_runtime(SEED_CLEAR)
var rain_side := _new_runtime(SEED_CLEAR)
var clear_runtime: Variant = clear_side[&"runtime"]
var rain_runtime: Variant = rain_side[&"runtime"]
_expect(clear_runtime.enable_production_operations()[&"ok"] and rain_runtime.enable_production_operations()[&"ok"], "enable production arrivals on both sides")
_expect(rain_runtime.weather.schedule(&"rain", 361)[&"ok"] and rain_runtime.weather.schedule(&"clear", 1200)[&"ok"], "rain from 06:01 on one side only")
var compared := false
for _minute in range(300):
clear_side[&"clock"].advance_minutes(1)
rain_side[&"clock"].advance_minutes(1)
var clear_reports: Array = clear_runtime.operations_coordinator.get_arrival_reports()
var rain_reports: Array = rain_runtime.operations_coordinator.get_arrival_reports()
# Only boundaries inside the rain (from 06:01) are compared.
if clear_reports.is_empty() or int(clear_reports[-1][&"minute"]) <= 360 or int(clear_reports[-1][&"total_count"]) < 2:
continue
var minute: int = clear_reports[-1][&"minute"]
var clear_total: int = clear_reports[-1][&"total_count"]
_expect(not rain_reports.is_empty() and int(rain_reports[-1][&"minute"]) == minute
and int(rain_reports[-1][&"total_count"]) == clear_total / 2, "rain plans floor(%d / 2) arrivals at minute %d" % [clear_total, minute])
var carry_same := true
for profile: Dictionary in clear_runtime.operations_coordinator.get_demand_profiles():
carry_same = carry_same and clear_runtime.operations_coordinator.get_arrival_carry(profile[&"facility_tag"], profile[&"guest_class"], profile[&"time_band"]) \
== rain_runtime.operations_coordinator.get_arrival_carry(profile[&"facility_tag"], profile[&"guest_class"], profile[&"time_band"])
_expect(carry_same, "arrivals lost to rain are not carried to the next boundary")
compared = true
break
_expect(compared, "an arrival boundary with at least two arrivals was compared")
var rain_capture: Dictionary = rain_runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null)
var clear_capture: Dictionary = clear_runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null)
_expect(int(rain_capture[&"arrival_percent"]) == 50 and int(clear_capture[&"arrival_percent"]) == 100, "the arrival boundary reports the rain share")
## F3 and F7 (same process): a guest due to leave in rain stays once, up to the rain end or 240 minutes.
func _test_rain_stay_and_saves() -> void:
var rainy := _new_runtime(SEED_RAIN)
var runtime: Variant = rainy[&"runtime"]
var guest: Variant = _spawn(runtime, &"p07_rain_guest", &"traveler", 362)
# Spawned 06:00, entering 06:01, deciding 06:02, first exit check at 06:03.
rainy[&"clock"].advance_minutes(3)
var hold: Dictionary = runtime.weather.get_rain_hold(&"p07_rain_guest")
_expect(hold == {"original_exit": 362, "extended_to": 602} and guest.planned_exit_at == 602, "a guest due at 06:02 in rain stays to 10:02 (+240, before the rain end 12:00) (%s)" % str(hold))
_expect(guest.exit_reason != &"PLANNED_EXIT", "the held guest did not leave at its first plan")
_expect(not runtime.call("_hold_guest_for_rain", guest, 400), "a guest is held only once")
var late: Variant = _spawn(runtime, &"p07_late_guest", &"traveler", 650)
_expect(runtime.call("_hold_guest_for_rain", late, 650) and late.planned_exit_at == 720, "a guest due at 10:50 stays to the rain end 12:00")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "rain, holds and the stream restore exactly in the same process")
var path := "user://tests/p07_weather_%d" % OS.get_process_id()
var weather_before: Dictionary = runtime.weather.create_snapshot()
var stream_before: Dictionary = runtime.get_rng_stream(&"weather").snapshot()
_expect(bool(runtime.save_to_path(path).get(&"ok", false)) and bool(runtime.load_from_path(path).get(&"ok", false))
and runtime.weather.create_snapshot() == weather_before and runtime.get_rng_stream(&"weather").snapshot() == stream_before
and runtime.guest_service.get_guest(&"p07_late_guest").planned_exit_at == 720, "an ordinary save file restores rain, holds and the weather stream")
SaveService.delete_save(path)
# The ordinary save operation records its own lifecycle; later checks compare with the loaded state.
saved = runtime.create_snapshot()
for bad_hold: Dictionary in [{"original_exit": 362, "extended_to": 603}, {"original_exit": 362, "extended_to": 362}]:
var malformed := saved.duplicate(true)
malformed[&"systems"][&"runtime"][&"weather"][&"rain_holds"]["p07_rain_guest"] = bad_hold
_expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == saved, "a hold beyond 240 minutes or not extending is refused atomically (%s)" % str(bad_hold))
var ghost := saved.duplicate(true)
ghost[&"systems"][&"runtime"][&"weather"][&"rain_holds"]["nobody"] = {"original_exit": 400, "extended_to": 500}
_expect(not runtime.restore_snapshot(ghost) and runtime.create_snapshot() == saved, "a hold of a missing guest is refused atomically")
var unmatched := saved.duplicate(true)
unmatched[&"systems"][&"runtime"][&"weather"][&"rain_holds"]["p07_late_guest"] = {"original_exit": 650, "extended_to": 700}
_expect(not runtime.restore_snapshot(unmatched) and runtime.create_snapshot() == saved, "a hold that differs from the guest plan is refused atomically")
var clear_runtime: Variant = _new_runtime(SEED_CLEAR)[&"runtime"]
var dry: Variant = _spawn(clear_runtime, &"p07_dry_guest", &"traveler", 361)
_expect(not clear_runtime.call("_hold_guest_for_rain", dry, 361) and dry.planned_exit_at == 361, "no hold without rain")
_expect(clear_runtime.weather.schedule(&"storm", 361)[&"ok"], "schedule a storm")
clear_runtime.weather.advance_to(361)
_expect(not clear_runtime.call("_hold_guest_for_rain", dry, 361), "no hold in a storm")
## F4: natural storm keeps zero new arrivals.
func _test_storm() -> void:
var stormy := _new_runtime(SEED_STORM)
var runtime: Variant = stormy[&"runtime"]
_expect(runtime.weather.get_kind() == &"storm" and String(runtime.weather.get_today_draw().get("kind", "")) == "storm" and int(runtime.weather.get_today_draw().get("end", -1)) == 480, "seed 2501 opens day 0 with a natural storm 06:00-08:00")
_expect(runtime.enable_production_operations()[&"ok"], "enable production arrivals in the storm")
var capture: Dictionary = runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null)
_expect(int(capture[&"capacity_remaining"]) == 0, "a natural storm exposes zero admission capacity")
var spawned := {&"count": 0}
runtime.guest_service.guest_spawned.connect(func(_guest: GuestRuntime) -> void: spawned[&"count"] += 1)
stormy[&"clock"].advance_minutes(119)
_expect(spawned[&"count"] == 0 and runtime.weather.get_kind() == &"storm", "no new guest enters during the natural storm")
stormy[&"clock"].advance_minutes(1)
_expect(runtime.weather.get_kind() == &"clear", "the storm returns to clear at 08:00")
## F5: mana fog x1.2 world tree and furnace yield (floor), adult intimacy increase x1.5 (floor).
func _test_mana_fog() -> void:
var foggy := _new_runtime(SEED_FOG)
var fog_runtime: Variant = foggy[&"runtime"]
var clear_side := _new_runtime(SEED_CLEAR)
var clear_runtime: Variant = clear_side[&"runtime"]
_expect(fog_runtime.weather.get_kind() == &"mana_fog" and int(fog_runtime.weather.get_today_draw().get("end", -1)) == 600, "seed 1592 opens day 0 with mana fog 06:00-10:00")
_expect(int(fog_runtime.get_mana_reserve_report()[&"generation"]) == 24 and int(clear_runtime.get_mana_reserve_report()[&"generation"]) == 20, "the reserve report shows yield 24 in fog and 20 in clear")
foggy[&"clock"].advance_minutes(10)
clear_side[&"clock"].advance_minutes(10)
_expect(int(fog_runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == 24 and int(clear_runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == 20, "the 06:10 boundary adds 24 in fog and 20 in clear")
var fog_saved: Dictionary = fog_runtime.create_snapshot()
_expect(fog_runtime.restore_snapshot(fog_saved) and fog_runtime.create_snapshot() == fog_saved, "a fog boundary flow restores")
var reserve := ManaReserveCycle.new()
var bad_reserve: Dictionary = fog_saved[&"systems"][&"runtime"][&"mana_reserve"].duplicate(true)
bad_reserve[&"last_flow"][&"generation"] = 23
_expect(not reserve.restore_snapshot(bad_reserve), "a yield that is neither 20 nor 24 is refused")
_expect(ManaReserveCycle.furnace_offer_accepted(160, 0) and not ManaReserveCycle.furnace_offer_accepted(160, 0, ManaReserveCycle.FOG_WORLD_TREE_GENERATION, ManaReserveCycle.FOG_FURNACE_YIELD), "a fog boundary judges the furnace with yield 24 and 120")
_expect(int(ManaReserveCycle.plan(100, 0, ManaReserveCycle.FOG_FURNACE_YIELD, 0, 10, ManaReserveCycle.FOG_WORLD_TREE_GENERATION)[&"after"]) == 244 and int(ManaReserveCycle.plan(200, 0, 120, 0, 10, 24)[&"after"]) == 300, "a fog furnace adds 120 and capacity 300 stays")
foggy[&"clock"].advance_minutes(230)
_expect(fog_runtime.weather.get_kind() == &"clear" and int(fog_runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == 20, "after the fog the yield is 20 again")
var adult: Variant = _spawn(fog_runtime, &"p07_adult", &"adventurer", 2000)
var minor: Variant = _spawn(fog_runtime, &"p07_other", &"traveler", 2000)
var fog_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_adult", {&"intimacy_day": 3, &"mana_fog": true})
var plain_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_adult", {&"intimacy_day": 3, &"mana_fog": false})
var other_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_other", {&"intimacy_day": 3, &"mana_fog": true})
var down_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_adult", {&"need_effects": {&"intimacy": -4}, &"mana_fog": true})
_expect(int(fog_report[&"deltas"]["intimacy"]) == 4 and int(plain_report[&"deltas"]["intimacy"]) == 3, "fog raises an adult-verified increase of 3 to floor(4.5) = 4")
_expect(int(other_report[&"deltas"]["intimacy"]) == 0 and int(down_report[&"deltas"]["intimacy"]) == -4, "fog leaves guests without adult verification and decreases unchanged")
_expect(not fog_runtime.guest_service.update_needs(&"p07_adult", {&"mana_fog": 1})[&"ok"], "a non-boolean fog context is refused")
var vital_fog := _new_runtime(SEED_FOG)
var vital_clear := _new_runtime(SEED_CLEAR)
var fog_guest: Variant = _spawn(vital_fog[&"runtime"], &"p07_fog_adult", &"adventurer", 2000)
var clear_guest: Variant = _spawn(vital_clear[&"runtime"], &"p07_clear_adult", &"adventurer", 2000)
vital_fog[&"clock"].advance_minutes(1)
vital_clear[&"clock"].advance_minutes(1)
_expect(int(fog_guest.needs[&"intimacy"]) == 4 and int(clear_guest.needs[&"intimacy"]) == 3, "the ordinary minute raises intimacy by 4 in fog and 3 in clear (day)")
_expect(adult != null and minor != null, "fog guests exist")
## F1 and F7: a pre-natural save migrates without a past draw; malformed natural state is refused.
func _test_migration_and_rejection() -> void:
var opened := _new_runtime(SEED_RAIN)
var runtime: Variant = opened[&"runtime"]
opened[&"clock"].advance_minutes(5)
var saved: Dictionary = runtime.create_snapshot()
var legacy := saved.duplicate(true)
var legacy_weather: Dictionary = legacy[&"systems"][&"runtime"][&"weather"]
legacy_weather.erase(&"natural")
legacy_weather.erase(&"rain_holds")
legacy_weather[&"version"] = 1
legacy[&"systems"][&"runtime"][&"rng_streams"].erase("weather")
_expect(runtime.restore_snapshot(legacy), "a version 1 weather save without the weather stream loads")
_expect(runtime.weather.get_kind() == &"rain" and runtime.weather.get_last_draw_day() == 0 and runtime.weather.get_today_draw().is_empty() and runtime.weather.get_rain_holds().is_empty(), "the migration keeps the current weather and does not draw for the past")
_expect(runtime.get_rng_stream(&"weather").snapshot() == DeterministicRng.from_root_seed(SEED_RAIN, &"weather").snapshot(), "the migrated weather stream starts from the world seed")
var migrated: Dictionary = runtime.create_snapshot()
var missing_stream := saved.duplicate(true)
missing_stream[&"systems"][&"runtime"][&"rng_streams"].erase("weather")
_expect(not runtime.restore_snapshot(missing_stream) and runtime.create_snapshot() == migrated, "a natural-weather save without its stream is refused atomically")
var wrong_day := saved.duplicate(true)
wrong_day[&"systems"][&"runtime"][&"weather"][&"natural"][&"last_draw_day"] = 1
_expect(not runtime.restore_snapshot(wrong_day) and runtime.create_snapshot() == migrated, "a draw day after the saved minute is refused atomically")
var unknown_kind := saved.duplicate(true)
unknown_kind[&"systems"][&"runtime"][&"weather"][&"natural"][&"today"]["kind"] = "snow"
_expect(not runtime.restore_snapshot(unknown_kind) and runtime.create_snapshot() == migrated, "an unknown drawn kind is refused atomically")
var overlap := saved.duplicate(true)
overlap[&"systems"][&"runtime"][&"weather"][&"pending"] = [{"kind": "storm", "minute": 700}, {"kind": "clear", "minute": 700}]
_expect(not runtime.restore_snapshot(overlap) and runtime.create_snapshot() == migrated, "overlapping weather changes are refused atomically")
## F6: the ordinary Main HUD shows the weather, the forecast and the effect guidance.
func _test_hud_forecast() -> void:
var clock: Variant = root.get_node("GameClock")
var state: Variant = root.get_node("GameState")
var runtime: Variant = root.get_node("SimulationRuntime")
clock.reset(1, 6, 0)
state.reset()
_expect(runtime.initialize(clock, state, SEED_RAIN)[&"valid"], "initialize the ordinary Runtime with seed 1106")
clock.set_paused(true)
var main := load("res://scenes/main/main.tscn").instantiate() as Control
main.set("manual_save_path", "user://tests/p07_weather_main_%d/manual_ui" % OS.get_process_id())
root.add_child(main)
await process_frame
await process_frame
main.call("_refresh_dashboard")
var weather_label := main.get("weather_label") as Label
var forecast_label := main.get("weather_forecast_label") as Label
var report: Dictionary = runtime.get_weather_report()
_expect(weather_label.text == "雨" and report[&"kind"] == &"rain", "HUD shows the Runtime rain")
_expect(forecast_label != null and forecast_label.text == "次: 晴れ 12:00" and report[&"next_change"] == {&"kind": &"clear", &"minute": 720}, "HUD forecast shows the next change from the Runtime (%s)" % (forecast_label.text if forecast_label != null else "none"))
_expect(String(report[&"effect_text"]) in weather_label.tooltip_text and "新規来客が半分" in weather_label.tooltip_text, "HUD guidance explains the rain effect")
clock.advance_minutes(360)
main.call("_refresh_dashboard")
_expect(weather_label.text == "晴れ" and forecast_label.text == "次: 予定なし" and "影響はありません" in weather_label.tooltip_text, "HUD follows the end of the rain")
var teardown: Dictionary = await AudioTeardown.free_owner_and_wait(self, main)
_expect(bool(teardown.get(&"passed", false)) and not bool(teardown.get(&"deadline_reached", true)), "owned audio references release before bounded teardown deadline with whole Runtime unchanged")
func _expect(condition: bool, message: String) -> void:
_checks += 1
if condition:
return
_failures += 1
push_error(message)
@@ -0,0 +1 @@
uid://bsaoksmz5s26w
+7 -2
View File
@@ -20,10 +20,15 @@ func run() -> void:
legacy[&"systems"][&"runtime"].erase(&"weather") legacy[&"systems"][&"runtime"].erase(&"weather")
legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness") legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness")
expect(runtime.restore_snapshot(legacy) and runtime.weather.get_kind() == &"clear" and runtime.weather.get_minute() == 360, "v10 migrates to neutral weather at saved minute") expect(runtime.restore_snapshot(legacy) and runtime.weather.get_kind() == &"clear" and runtime.weather.get_minute() == 360, "v10 migrates to neutral weather at saved minute")
expect(runtime.create_snapshot() == original, "old neutral save migration preserves other state") # P07-01 (A13): the original carries the opening day's natural draw, which a
# pre-weather save never had; every other system must stay byte-equal.
var migrated: Dictionary = runtime.create_snapshot()
var migrated_rest: Dictionary = migrated.duplicate(true); migrated_rest[&"systems"][&"runtime"].erase(&"weather")
var original_rest: Dictionary = original.duplicate(true); original_rest[&"systems"][&"runtime"].erase(&"weather")
expect(migrated_rest == original_rest and runtime.weather.get_last_draw_day() == -1 and runtime.weather.get_today_draw().is_empty(), "old neutral save migration preserves other state")
var malformed := original.duplicate(true) var malformed := original.duplicate(true)
malformed[&"systems"][&"runtime"][&"weather"][&"minute"] = 361 malformed[&"systems"][&"runtime"][&"weather"][&"minute"] = 361
expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == original, "future weather clock rejects atomically") expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == migrated, "future weather clock rejects atomically")
malformed = original.duplicate(true) malformed = original.duplicate(true)
malformed[&"systems"][&"runtime"].erase(&"weather") malformed[&"systems"][&"runtime"].erase(&"weather")
expect(not runtime.restore_snapshot(malformed), "new schema cannot silently lose weather") expect(not runtime.restore_snapshot(malformed), "new schema cannot silently lose weather")
+5 -1
View File
@@ -32,7 +32,11 @@ func run() -> void:
var completed: Dictionary = runtime.create_snapshot() var completed: Dictionary = runtime.create_snapshot()
expect(runtime.restore_snapshot(completed) and runtime.create_snapshot() == completed, "completed retry restores exactly") expect(runtime.restore_snapshot(completed) and runtime.create_snapshot() == completed, "completed retry restores exactly")
clock.advance_minutes(30) clock.advance_minutes(30)
expect(runtime.weather.get_kind() == &"clear" and runtime.weather.create_snapshot()[&"pending"].is_empty(), "disk-restored recovery occurs once at scheduled minute") # P07-01 (A13): only the opening day's natural draw may remain scheduled.
var today: Dictionary = runtime.weather.get_today_draw()
var natural_pending: Array = [] if String(today.get("kind", "clear")) == "clear" else [
{&"kind": StringName(today["kind"]), &"minute": int(today["start"])}, {&"kind": &"clear", &"minute": int(today["end"])}]
expect(runtime.weather.get_kind() == &"clear" and runtime.weather.create_snapshot()[&"pending"] == natural_pending, "disk-restored recovery occurs once at scheduled minute")
expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "recovery resumes ordinary operations") expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "recovery resumes ordinary operations")
expect(SaveService.delete_save(path), "delete isolated test save") expect(SaveService.delete_save(path), "delete isolated test save")
runtime.free(); clock.free(); state.free() runtime.free(); clock.free(); state.free()
+1 -1
View File
@@ -32,7 +32,7 @@ func _initialize() -> void:
for field: String in ["version", "minute", "kind", "since", "pending"]: for field: String in ["version", "minute", "kind", "since", "pending"]:
var bad := baseline.duplicate(true); bad.erase(field) var bad := baseline.duplicate(true); bad.erase(field)
expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "missing field rejects atomically: " + field) expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "missing field rejects atomically: " + field)
for change: Dictionary in [{"version": 2}, {"minute": 380.0}, {"kind": "snow"}, {"since": 381}, {"pending": [{"kind":"clear","minute":380}]}, {"pending": [{"kind":"clear","minute":400},{"kind":"rain","minute":390}]}, {"extra":true}]: for change: Dictionary in [{"version": 3}, {"minute": 380.0}, {"kind": "snow"}, {"since": 381}, {"pending": [{"kind":"clear","minute":380}]}, {"pending": [{"kind":"clear","minute":400},{"kind":"rain","minute":390}]}, {"extra":true}]:
var bad := baseline.duplicate(true) var bad := baseline.duplicate(true)
bad.merge(change, true) bad.merge(change, true)
expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "malformed or contradictory state rejects atomically") expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "malformed or contradictory state rejects atomically")
@@ -0,0 +1,105 @@
extends RefCounted
## P07-01 F7: the fixture and minute script shared by the restart writer
## (tests/environment/test_p07_weather_restart_1_write.gd) and reader
## (tests/environment/test_p07_weather_restart_2_read.gd), which run as separate
## engine processes. Seed 1106 opens day 0 with rain 06:00-12:00 (A13), a guest
## due at 06:02 is held to 10:02, production arrivals run, and day 1 draws at 06:00.
const HANDOFF_PATH := "res://artifacts/p07-01/restart-handoff.json"
const HANDOFF_SCHEMA := "p07-01-restart-handoff-v1"
const WRITER_SOURCE := "res://tests/environment/test_p07_weather_restart_1_write.gd"
const WRITER_SAVE := "user://p07_restart_write/minute_%d"
const READER_SAVE := "user://p07_restart_read/minute_%d"
const SEED := 1106
const FIRST_MINUTE := 360
# Mid-rain, just before and just after the rain end, just before and just after the day 1 draw.
const SAVE_MINUTES: Array[int] = [500, 719, 720, 1799, 1800]
const LAST_MINUTE := 1830
const DRAW_MINUTES: Array[int] = [1800]
const HELD_GUEST := &"p07_restart_guest"
var _expect: Callable
func _init(expect: Callable) -> void:
_expect = expect
func new_fixture(with_guest := true) -> Dictionary:
var clock: Variant = load("res://autoload/game_clock.gd").new()
var state: Variant = load("res://autoload/game_state.gd").new()
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
clock.reset(1, 6, 0)
state.reset()
_expect.call(bool(runtime.initialize(clock, state, SEED).get(&"valid", false)), "fixture Runtime initializes")
clock.set_paused(true)
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
_expect.call(bool(runtime.enable_production_operations().get(&"ok", false)), "fixture enables production Operations")
var fixture := {&"clock": clock, &"state": state, &"runtime": runtime, &"draws": {&"count": 0}}
if with_guest:
var result: CommandResult = runtime.submit_command(&"spawn_guest", {
&"guest_id": HELD_GUEST,
&"guest_type_id": &"traveler",
&"gold": 100,
&"options": {
&"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT),
&"needs": {&"hunger": 10, &"sleep": 10, &"hygiene": 10, &"medical": 0, &"leisure": 10, &"intimacy": 0},
&"planned_exit_at": 362,
},
}, &"world", HELD_GUEST, 10, -1, &"p07.restart.guest")
_expect.call(result.is_success(), "fixture spawns the guest due at 06:02")
watch_draws(fixture)
return fixture
func watch_draws(fixture: Dictionary) -> void:
var draws: Dictionary = fixture[&"draws"]
fixture[&"runtime"].domain_events.subscribe(&"WEATHER_FORECAST_DRAWN", &"p07_restart",
func(_event: SimulationDomainEvent) -> void: draws[&"count"] += 1)
func free_fixture(fixture: Dictionary) -> void:
for key: StringName in [&"runtime", &"clock", &"state"]:
if fixture.get(key) != null and is_instance_valid(fixture[key]):
fixture[key].free()
## Advances one minute at a time after `after_minute` through `through_minute`
## and calls `on_minute` after each minute (the writer saves there).
func play(fixture: Dictionary, after_minute: int, through_minute: int, on_minute: Callable) -> void:
var clock: Variant = fixture[&"clock"]
_expect.call(clock.absolute_minute == after_minute, "play starts at minute %d" % after_minute)
for minute: int in range(after_minute + 1, through_minute + 1):
clock.advance_minutes(1)
if on_minute.is_valid():
on_minute.call(minute)
## The weather-facing state compared across processes: weather and its stream,
## guest plans, the reserve flow and the arrival totals.
func state_value(runtime: Variant) -> Dictionary:
var plans := {}
for guest: GuestRuntime in runtime.guest_service.get_guests():
plans[String(guest.guest_id)] = guest.planned_exit_at
var arrivals := 0
for report: Dictionary in runtime.operations_coordinator.get_arrival_reports():
arrivals += int(report[&"total_count"])
return {
"minute": runtime.weather.get_minute(),
"weather": runtime.weather.create_snapshot(),
"stream": runtime.get_rng_stream(&"weather").snapshot(),
"plans": plans,
"reserve_flow": runtime.mana_reserve.get_last_flow(),
"arrivals": arrivals,
}
func state_bytes(runtime: Variant) -> PackedByteArray:
return var_to_bytes(state_value(runtime))
static func sha256(bytes: PackedByteArray) -> String:
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
context.update(bytes)
return context.finish().hex_encode()
@@ -0,0 +1 @@
uid://bczrqi1qiv5zk
+25
View File
@@ -438,6 +438,28 @@
"old_bounded_teardown": false, "old_bounded_teardown": false,
"new_audio_QA": false "new_audio_QA": false
} }
,
"tests/environment/test_p07_weather_runtime.gd": {
"source_sha256": "11caac7e652213a708c270e136853b0fa053bcda502576da741f1f5d67cdd64b",
"ordinary_cleanup_prepared": false,
"ordinary_control_labels": [],
"old_bounded_teardown": true,
"new_audio_QA": false
},
"tests/environment/test_p07_weather_restart_1_write.gd": {
"source_sha256": "ad8067f42348b803ce7736bee2f55f4b0789fefaeaaac702ff8a8800d926d8ea",
"ordinary_cleanup_prepared": false,
"ordinary_control_labels": [],
"old_bounded_teardown": false,
"new_audio_QA": false
},
"tests/environment/test_p07_weather_restart_2_read.gd": {
"source_sha256": "fb0a633c08cca1de66dc549f760f2a8e822c6c14c54f58e609de7f78c22fb469",
"ordinary_cleanup_prepared": false,
"ordinary_control_labels": [],
"old_bounded_teardown": false,
"new_audio_QA": false
}
}, },
"outside_routes": { "outside_routes": {
"tests/supply/test_normal_auto_purchase_chain.gd": { "tests/supply/test_normal_auto_purchase_chain.gd": {
@@ -474,6 +496,9 @@
"tests/economy/test_p05_funds_restart_1_write.gd": [ "tests/economy/test_p05_funds_restart_1_write.gd": [
"artifacts/p05-02/restart-handoff.json" "artifacts/p05-02/restart-handoff.json"
], ],
"tests/environment/test_p07_weather_restart_1_write.gd": [
"artifacts/p07-01/restart-handoff.json"
],
"tests/mvp/test_runtime_upkeep.gd": [ "tests/mvp/test_runtime_upkeep.gd": [
"artifacts/upkeep-2026-09-29/preclose-snapshot.txt", "artifacts/upkeep-2026-09-29/preclose-snapshot.txt",
"artifacts/upkeep-2026-09-29/closed-snapshot.txt" "artifacts/upkeep-2026-09-29/closed-snapshot.txt"
+632
View File
@@ -0,0 +1,632 @@
#!/usr/bin/env python3
"""Root-owned narrow P07-01 (natural weather, rain, mana fog, storm, HUD forecast; A13) collection using the
unchanged guarded phase protocol of tools/run_p05_funds_tests.py (itself the protocol of
tools/run_p18_relationship_tests.py). After the roster, the F7 restart writer and reader suites run as two more
phases (separate engine processes); the writer's only declared generated report is the save handoff the reader loads."""
import argparse
import hashlib
import json
import os
import re
import stat
from pathlib import Path
import shutil
import sys
import types
import uuid
sys.dont_write_bytecode = True
import run_all_tests as shared
ROSTER = [
'tests/environment/test_p07_weather_runtime.gd',
'tests/environment/test_runtime_weather.gd',
'tests/environment/test_weather_timeline.gd',
'tests/environment/test_weather_recovery.gd',
'tests/mvp/test_runtime_operations_integration.gd',
'tests/s2/test_runtime_operations_cycle.gd',
'tests/s2/test_runtime_mana_reserve.gd',
'tests/s2/test_mana_reserve_cycle.gd',
'tests/s3/test_guest_service.gd',
'tests/s6/test_runtime_save_lifecycle.gd',
'tests/s6/test_runtime_snapshot_serialization.gd',
'tests/ui/test_main_dashboard.gd',
]
# P07-01 F7 (SPEC section 8): the writer saves, the reader in the next phase's engine resumes.
RESTART = [
'tests/environment/test_p07_weather_restart_1_write.gd',
'tests/environment/test_p07_weather_restart_2_read.gd',
]
HANDOFF = 'artifacts/p07-01/restart-handoff.json'
SUITES = ROSTER + RESTART
WRITER_LINE = r'(?m)^P07_RESTART_WRITER pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$'
READER_LINE = r'(?m)^P07_RESTART_READER pid=([0-9]+) writer_pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$'
def collect(args, root, out, refs, markers, contracts, policy, contract_path, contract_sha, parse_paths):
source_bytes = sum(pin['bytes'] for pin in refs.values())
free = shutil.disk_usage(out.parent).free
needed = source_bytes + shared.MIN_SPACE
shared.require(free >= needed, 'fresh source and phase disk admission')
outer = shared.module('p07_owned_outer', shared.SUPPORT / 'owned_outer.py')
shared.require(not outer.native.helper['live_engines'](), 'another Godot owns CPU')
engine = shutil.which('godot')
shared.require(engine and Path(engine).is_file(), 'native launcher exists')
engine = str(Path(engine).resolve())
out.mkdir(parents=True, exist_ok=False)
stage = out / 'stage'
stage.mkdir()
(out / 'starters').mkdir()
(out / 'bootstrap').mkdir()
for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items():
data = pin['text'].encode('utf-8')
shared.require(hashlib.sha256(data).hexdigest() == pin['sha256'], 'unchanged external bootstrap bytes')
(out / 'bootstrap' / name).write_bytes(data)
protected = shared.default_player(root)
expected = {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} for name, pin in refs.items()}
selected = {'root_before': refs, 'map': {name: pin['sha256'] for name, pin in refs.items()}, 'timeout': args.timeout}
for name, pin in refs.items():
target = stage / name
target.parent.mkdir(parents=True, exist_ok=True)
shared.copy_source(Path(pin['path']), target)
shared.require(shared.inventory(stage, stage=True) == expected, 'complete private source copy')
public = (stage / 'project.godot').read_bytes()
shared.write(out / 'parse-paths.json', parse_paths)
run_id = uuid.uuid4().hex
shared.write(out / 'runner-input.json', dict(protocol='guarded-p07-01-v1', run_id=run_id,
owner_runner_pid=os.getpid(), project_root=str(root), stage=str(stage), roster=ROSTER,
restart_phases=RESTART, restart_handoff=HANDOFF,
selected_source_map=selected['map'], marker_contract_sha256=contract_sha,
runner_sha256=shared.sha(Path(__file__)), shared_runner_sha256=shared.sha(shared.HERE / 'run_all_tests.py'),
marker_module_sha256=shared.sha(shared.HERE / 'strict_test_markers.py'),
engine=engine, engine_sha256=shared.sha(engine), default_player_before=protected,
formal_acceptance=False, original_unit_complete=False))
shared.write(out / 'source-start.json', refs)
diagnostics = shared.module('p07_diagnostics', shared.SUPPORT / 'diagnostic_gate.py')
owners = shared.module('p07_owners', shared.SUPPORT / 'ordinary_proof_gate.py')
audio = shared.module('p07_audio', shared.SUPPORT / 'audio_evidence_gate.py')
phases, rows, previous = [], [], {}
error = None
try:
for phase, test in [('import', None), ('parse', None)] + [(str(i).zfill(3), name) for i, name in enumerate(SUITES)]:
shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged')
row = shared.execute_phase(engine, stage, out, phase, test, selected, expected, previous,
policy, contracts, parse_paths, public, protected, root, outer, markers, diagnostics, owners, audio)
archive_profiles(out, row)
phases.append(row)
if test:
row.update(sequence=len(rows), godot=engine)
rows.append(row)
(out / 'all-tests.json').write_text(json.dumps(rows, ensure_ascii=False, indent=2) + '\n')
print(('PASS ' if row['passed'] else 'FAIL ') + test, flush=True)
shared.require(row['passed'], 'first failed phase stops without retry: ' + phase)
except BaseException as caught:
error = type(caught).__name__ + ': ' + str(caught)
finally:
cleanup, cleanup_errors = outer.finish_captured(outer.OWNED)
guard_errors = []
try:
shared.require(shared.source_refs(root) == refs, 'whole source unchanged')
shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged at end')
shared.require(shared.default_player(root) == protected, 'default profile untouched')
shared.require(shared.inventory(stage, stage=True) == dict(expected, **previous), 'whole stage exact')
except BaseException as caught:
guard_errors.append(type(caught).__name__ + ': ' + str(caught))
ended = bool(outer.OWNED) and all(row['engine_ended'] is True for row in cleanup)
passed = error is None and not cleanup_errors and not guard_errors and ended and len(phases) == len(SUITES) + 2 and all(row['passed'] for row in phases)
context = shared.totals(rows, SUITES)
shared.write(out / 'runner-ended.json', dict(context, protocol='guarded-p07-01-v1', run_id=run_id,
owner_runner_pid=os.getpid(), passed=passed, engine_ended=ended,
actual_captured_PIDs=[process.pid for process in outer.OWNED], final_owned_cleanup=cleanup,
cleanup_errors=cleanup_errors, guard_errors=guard_errors, error=error, phases=phases,
all_tests_sha256=shared.sha(out / 'all-tests.json') if (out / 'all-tests.json').exists() else None,
default_player_after=shared.default_player(root), formal_acceptance=False,
original_unit_complete=False, independent_review='pending', native_Windows='pending'))
if passed:
seal(out, refs, engine, contract_sha)
# Root also exercises precisely the verifier that ordinary Runner uses.
verify(out, refs, markers, contracts, policy, contract_sha, parse_paths)
print(json.dumps(dict(context, passed=passed, error=error, cleanup_errors=cleanup_errors, guard_errors=guard_errors)), flush=True)
return 0 if passed else 1
# Receipt file names are always relative; absolute paths in native receipts are
# historical identity strings only and are never opened by the verifier.
def require(ok, reason):
shared.require(ok, reason)
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'),
ensure_ascii=False).encode()).hexdigest()
def content_map(refs):
return {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']}
for name, pin in refs.items()}
def plain_path(path):
path = Path(path)
require('..' not in path.parts, 'parent traversal refused')
absolute = Path(os.path.abspath(path))
for part in [absolute, *absolute.parents]:
require(not part.is_symlink(), 'symlink path refused: ' + str(part))
return absolute
def confined(out, name):
require(type(name) is str and name and '\\' not in name, 'ordinary relative evidence name')
path = Path(name)
require(not path.is_absolute() and '..' not in path.parts
and path.as_posix() == name and name != '.', 'evidence path escape or alias')
result = plain_path(out / path)
require(result.is_relative_to(out), 'evidence confined to output')
return result
def inventory(out):
result = {}
require(out.is_dir(), 'output directory required')
for directory, dirs, files in os.walk(out, followlinks=False):
for name in dirs + files:
p = Path(directory) / name
mode = p.lstat().st_mode
require(stat.S_ISREG(mode) or stat.S_ISDIR(mode), 'nonordinary evidence refused')
if stat.S_ISREG(mode):
require(p.stat().st_nlink == 1, 'hardlinked evidence refused')
result[p.relative_to(out).as_posix()] = {
'bytes': p.stat().st_size, 'sha256': shared.sha(p)}
return result
def unique_object(pairs):
result = {}
for key, value in pairs:
require(key not in result, 'duplicate JSON key')
result[key] = value
return result
def read_json(path):
return json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=unique_object,
parse_constant=lambda token: require(False, 'nonfinite JSON ' + token))
def archive_profiles(out, row):
for label in ('startup', 'runtime'):
value = row.get('whole_' + label + '_profile_after')
if value is None:
continue
dest = out / 'profiles' / row['phase'] / label
dest.mkdir(parents=True, exist_ok=False)
source = plain_path(value['path'])
require(shared.inventory(source) == value['files'], 'whole owned profile before archive')
for name in value['files']:
target = confined(out, str((dest / name).relative_to(out)))
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source / name, target)
require(shared.inventory(dest) == value['files'] == shared.inventory(source),
'whole owned profile archive exact')
def seal(out, refs, engine, contract_sha):
# Last file written: an interrupted collection has no complete receipt.
shutil.copyfile(engine, out / 'engine.bin')
require(shared.sha(out / 'engine.bin') == shared.sha(engine), 'engine archive exact')
source = content_map(refs)
shared.write(out / 'receipt.json', {
'schema': 'guarded-p07-01-v1', 'source_map': source,
'parse_checks': len([name for name in refs if name.endswith('.gd')]),
'source_map_sha256': digest(source),
'project_settings_sha256': source['project.godot']['sha256'],
'marker_contract_sha256': contract_sha,
'engine_sha256': shared.sha(engine),
'files': inventory(out),
})
def cleanup_gate(value):
require(value['owned_child_created'] is True and value['engine_ended'] is True
and value['actual_process_exit_code'] == 0 and value['errors'] == [],
'successful actual owned cleanup')
attempts = value['attempts']
require(1 <= len(attempts) <= 3, 'bounded cleanup attempts')
for index, row in enumerate(attempts):
require(row['attempt'] == index + 1 and row['errors'] == []
and row['wait_timeout_seconds'] == 5 and row['wait_completed'] is True
and row['wait_returned_code'] == 0 and row['poll_after'] == 0,
'complete bounded owned wait')
def outer_cleanup(rows, pids):
require([r['pid'] for r in rows] == pids, 'exact captured cleanup PID order')
for row in rows:
require(row['engine_ended'] is True and row['outer_cleanup_errors'] == []
and row['foreign_process_or_group_signals'] == 0
and row['outer_maximum_helper_invocations'] == 3
and row['helper_maximum_waits'] == 3 and row['per_wait_timeout_seconds'] == 5,
'unchanged outer cleanup contract')
cleanup_gate(row['cleanup'])
def verify(out, refs, markers, contracts, policy, contract_sha, parse_paths):
receipt_path = confined(out, 'receipt.json')
require(receipt_path.is_file(), 'regular final receipt required')
manifest = read_json(receipt_path)
require(manifest['schema'] == 'guarded-p07-01-v1', 'current receipt schema required')
files = manifest['files']
require(type(files) is dict and files, 'complete raw manifest')
for name, pin in files.items():
p = confined(out, name)
require(p.is_file() and set(pin) == {'bytes', 'sha256'}
and type(pin['bytes']) is int and pin['bytes'] >= 0
and re.fullmatch('[0-9a-f]{64}', pin['sha256']) is not None,
'ordinary artifact pin')
actual = inventory(out)
receipt_pin = actual.pop('receipt.json')
require(actual == files, 'complete raw file set and SHA-256 equality')
source = content_map(refs)
require(manifest['source_map'] == source and manifest['source_map_sha256'] == digest(source),
'current whole source content map independent of checkout path')
require(manifest['project_settings_sha256'] == source['project.godot']['sha256']
and manifest['marker_contract_sha256'] == contract_sha, 'current project/marker pins')
def read(name):
require(name in files, 'required raw file missing: ' + name)
return read_json(confined(out, name))
def text(name):
require(name in files, 'required raw transport missing: ' + name)
return confined(out, name).read_text(encoding='utf-8')
start, end = read('runner-input.json'), read('runner-ended.json')
require(start['protocol'] == end['protocol'] == 'guarded-p07-01-v1'
and start['run_id'] == end['run_id']
and re.fullmatch('[0-9a-f]{32}', start['run_id']) is not None,
'same current run')
require(type(start['owner_runner_pid']) is int and start['owner_runner_pid'] > 0
and start['owner_runner_pid'] == end['owner_runner_pid'], 'same collector owner')
require(start['roster'] == ROSTER and start['restart_phases'] == RESTART
and start['restart_handoff'] == HANDOFF and len(set(SUITES)) == len(SUITES),
'exact unique roster and restart tests')
require(start['selected_source_map'] == {n: p['sha256'] for n, p in source.items()}
and content_map(read('source-start.json')) == source, 'whole selected source pins')
for field, name in [('runner_sha256', 'tools/run_p07_weather_tests.py'),
('shared_runner_sha256', 'tools/run_all_tests.py'),
('marker_module_sha256', 'tools/strict_test_markers.py')]:
require(start[field] == source[name]['sha256'], 'current collector/shared/marker pin')
require(start['marker_contract_sha256'] == contract_sha, 'marker contract pin')
# Resolve only the locally configured launcher, never the receipt's engine path.
engine = shutil.which('godot')
require(engine and shared.sha(engine) == start['engine_sha256']
== manifest['engine_sha256'] == files['engine.bin']['sha256'],
'current native engine and retained bytes pinned')
derived_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')]
require(read('parse-paths.json') == parse_paths == derived_paths
and manifest['parse_checks'] == len(derived_paths),
'all exact source parse paths and receipt count')
require(end['passed'] is True and end['engine_ended'] is True and end['error'] is None
and end['cleanup_errors'] == [] and end['guard_errors'] == [],
'finished successful collection without guard/cleanup error')
for receipt in (start, end):
require(receipt['formal_acceptance'] is False and receipt['original_unit_complete'] is False,
'backend evidence never completes original unit')
before = start['default_player_before']
require(before == end['default_player_after'] and before['logs_and_locks_included'] is True
and type(before['existed']) is bool and type(before['files']) is dict,
'complete default player before/after equality')
for name, pin in before['files'].items():
confined(out, name) # Validate the inventory name; never open the player.
require(set(pin) == {'bytes', 'sha256'} and type(pin['bytes']) is int
and pin['bytes'] >= 0 and re.fullmatch('[0-9a-f]{64}', pin['sha256']),
'whole default player file pin')
require(before['existed'] or before['files'] == {}, 'absent player has no files')
native = shared.module('p07_verify_native', shared.SUPPORT / 'owned_diagnostic_process.py')
diagnostics = shared.module('p07_verify_diagnostics', shared.SUPPORT / 'diagnostic_gate.py')
owners = shared.module('p07_verify_owners', shared.SUPPORT / 'ordinary_proof_gate.py')
audio = shared.module('p07_verify_audio', shared.SUPPORT / 'audio_evidence_gate.py')
# Helpers are loaded for their pure gates only. No run/probe/cleanup is called.
origin = Path(start['stage']).parent
require(Path(start['stage']) == origin / 'stage' and origin.is_absolute()
and '..' not in origin.parts, 'original output identity')
def relocated(value):
if isinstance(value, dict):
return {relocated(k): relocated(v) for k, v in value.items()}
if isinstance(value, list):
return [relocated(v) for v in value]
if isinstance(value, str) and value.startswith(str(origin) + '/'):
return str(confined(out, value[len(str(origin)) + 1:]))
return value
def proof_read(path):
path = plain_path(path)
require(path.is_relative_to(out), 'proof path stays in output')
return relocated(read(path.relative_to(out).as_posix()))
owners.read = proof_read
# The bounded teardown gate parses its owner proof itself and pins the snapshots by their recorded
# absolute paths; this private module instance reads them relocated, as the ordinary gate does.
audio.json = types.SimpleNamespace(loads=lambda data: relocated(json.loads(data)))
phases = end['phases']
require(len(phases) == len(SUITES) + 2, 'exact import, parse, roster and restart phases')
tests = read('all-tests.json')
stage_files = shared.inventory(confined(out, 'stage'), stage=True)
require(set(stage_files) == set(source) | {HANDOFF}, 'retained stage is the source and the restart handoff')
handoff = stage_files[HANDOFF]
require(len(tests) == len(SUITES) and end['all_tests_sha256'] == files['all-tests.json']['sha256'],
'complete test rows')
pids = []
for index, (phase, test) in enumerate([('import', None), ('parse', None)]
+ [(str(i).zfill(3), n) for i, n in enumerate(SUITES)]):
row = read(phase + '-receipt.json')
expected_row = dict(row)
if test:
expected_row.update(sequence=index - 2, godot=start['engine'])
require(tests[index - 2] == expected_row, 'test row matches raw phase receipt')
require(phases[index] == expected_row and row['phase'] == phase and row['test'] == test
and row['passed'] is True and row['failed'] is False
and row['validation_error'] is None and not row.get('guard_error')
and row['default_player_entire_profile_unchanged'] is True,
'exact successful phase')
# The same proof classification as the shared collector (tools/run_all_tests.py).
p = shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) if test else None
ordinary = bool(p) and p['ordinary_cleanup_prepared'] is True
bounded = bool(p) and p['old_bounded_teardown'] is True
pid = row['actual_pid']
require(type(pid) is int and pid > 0 and pid not in pids, 'distinct actual PIDs per phase')
pids.append(pid)
process = read(phase + '/process.json')
require(process == row['native_process'] and process['pid'] == pid
and process['owned_child_process_helper_sha256'] == native.HELPER_SHA
== source['tools/regression/p09_owned_engine_process.py']['sha256'],
'actual phase/process/helper agreement')
stdout, stderr, logger = (text(phase + '/' + n) for n in
('stdout.log', 'stderr.log', 'engine-native.log'))
raw = stdout + stderr
require(text(row['log']) == raw and row['log_sha256'] == files[row['log']]['sha256'],
'literal complete raw stream')
require(process['stdout_sha256'] == files[phase + '/stdout.log']['sha256']
and process['stderr_sha256'] == files[phase + '/stderr.log']['sha256'],
'process transport SHA')
require(row['raw_native_log'] == dict(files[phase + '/engine-native.log'],
path=str(origin / phase / 'engine-native.log')), 'native logger pin')
review = diagnostics.review_streams(native, {
'stdout': stdout, 'stderr': stderr, 'native_file': logger}, test)
require(row['finite_original_diagnostic_review'] == review
and diagnostics.diagnostic_result_gate(process, review)
and shared.process_gate(process, review['allowed_e31_error_lines']),
'strict diagnostics/process gates')
require(native.native_diagnostics(stdout, stderr, row['command'])
== process['strict_native_diagnostics'], 'raw banner/diagnostics recomputed')
command = [start['engine'], '--headless', '--path', start['stage']]
if phase == 'import':
command += ['--editor', '--import', '--quit', '--verbose']
else:
script = ('bootstrap/parse_full_dependencies.gd' if phase == 'parse'
else 'starters/' + phase + '.gd')
command += ['--script', str(origin / script), '--verbose']
require(row['starter_sha256'] == files[script]['sha256'], 'exact starter pin')
if test:
expected_starter = ('extends ' + json.dumps('res://' + test) + '\nconst Guard := preload('
+ json.dumps(str(origin / 'bootstrap/profile_guard.gd'))
+ ')\nfunc _initialize() -> void:\n\tif not Guard.activate():\n\t\tquit(97)\n\t\treturn\n\tsuper._initialize()\n')
require(text(script) == expected_starter, 'unchanged guarded starter bytes')
command += ['--log-file', str(origin / phase / 'engine-native.log')]
require(row['command'] == process['argv'] == command, 'exact native argv/logger')
for filename, field, event in [('started.json', 'actual_start_binding', 'ENGINE_STARTED'),
('ended.json', 'actual_end_binding', 'ENGINE_ENDED')]:
binding = read(phase + '/' + filename)
require(binding == process[field] and binding['event'] == event
and binding['actual_pid'] == binding['pid'] == pid
and binding['owner_pid'] == start['owner_runner_pid']
and binding['command'] == binding['argv'] == command, 'actual START/END ownership')
binding = process['actual_start_binding']
identity = binding['current_process_identity']
if identity is None:
require(binding['tracked_child_ended_before_initial_probe'] is True, 'early ended tracked child')
else:
require(binding['live_identity_exact'] is True and identity == {
'pid': pid, 'ppid': start['owner_runner_pid'], 'pgid': pid, 'args': ' '.join(command)},
'recorded live child PID/PPID/PGID/argv')
finish = process['actual_end_binding']
require(finish['engine_ended'] is True and finish['timed_out'] is False
and finish['lifecycle_error'] is None and finish['exit_code'] == 0
and finish['actual_process_exit_code'] == 0
and finish['start_receipt_written'] is True
and finish['monotonic_ns'] >= binding['monotonic_ns'], 'complete native END')
cleanup = read(phase + '/adapter-owned-cleanup.json')
require(cleanup == process['adapter_owned_cleanup'], 'raw adapter cleanup')
cleanup_gate(cleanup)
capture = row['outer_actual_capture']
require(read(phase + '/outer-owned-finally.json') == {
k: v for k, v in capture.items()
if k not in ('outer_receipt_written', 'outer_receipt_error', 'passed')},
'whole raw outer receipt')
require(capture['actual_captured_pids'] == [pid] and capture['passed'] is True
and capture['all_captured_ended'] is True and capture['adapter_exception'] is None
and capture['outer_cleanup_errors'] == [] and capture['outer_receipt_error'] is None
and capture['outer_receipt_written'] is True
and capture['all_cleanup_before_outer_receipt'] is True
and capture['foreign_process_or_group_signals'] == 0
and row['engine_ended'] is True and row['exit_code'] == 0, 'owned outer capture')
outer_cleanup(capture['cleanup'], [pid])
startup = read(phase + '-startup-profile-before.json')
require(startup == {'path': row['whole_startup_profile_after']['path'], 'existed': False,
'files': {}, 'before_native_spawn': True}, 'fresh startup profile')
require(row['whole_startup_profile_after']['fresh_before_engine'] is True,
'startup fresh before engine')
name = row['profile_startup_name']
require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-startup-' + phase, name)
and Path(startup['path']).name == name and row['private_project_fields'] == 2
and row['private_project_sha256'] == hashlib.sha256(shared.private_project(
(shared.HERE.parent / 'project.godot').read_bytes(), name)).hexdigest(),
'exact two-field startup isolation')
for label in ('startup', 'runtime'):
if label == 'runtime' and phase == 'import':
continue
whole = row['whole_' + label + '_profile_after']
archive = confined(out, 'profiles/' + phase + '/' + label)
require(archive.is_dir() and shared.inventory(archive)
== whole['files'], 'complete retained private profile')
if phase != 'import':
guard = read(phase + '-profile.json')
runtime = Path(row['whole_runtime_profile_after']['path'])
name = guard['custom_user_dir_name']
require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-runtime-' + phase, name)
and runtime.name == name and guard['verified'] is True
and guard['conditions'] == [True] * 5 and guard['conditions_checked'] == 5
and guard['fresh_before_parent_initialize'] is True
and guard['profile_writes_before_guard'] == 0
and guard['actual_user_data_dir'] == guard['expected_user_data_dir'] == str(runtime)
and guard['globalized_probe'] == str(runtime / 'guard-probe')
and guard['project_settings_persisted'] is False, 'all unchanged Guard conditions')
require(row['actual_private_profile'] == {
'report': guard, 'report_sha256': files[phase + '-profile.json']['sha256'],
'whole_files': row['whole_runtime_profile_after']['files']}, 'whole raw Guard report')
require(row['controlled_environment'] == {
'THE_TREE_PACK_PROFILE_NAME': name, 'THE_TREE_PACK_PROFILE_EXPECTED': str(runtime),
'THE_TREE_PACK_PROFILE_REPORT': str(origin / (phase + '-profile.json')),
'THE_TREE_FULL_PARSE_PATHS': str(origin / 'parse-paths.json'),
**({'THE_TREE_AUDIO_TEARDOWN_PROOF_DIR': str(origin / (phase + '-teardown/owned-audio')),
'THE_TREE_AUDIO_FIXTURE_CONTROL_PROOF': str(origin / (phase + '-teardown/control.json'))}
if ordinary else {'THE_TREE_AUDIO_TEARDOWN_PROOF_DIR': str(origin / (phase + '-teardown'))}
if bounded else {})},
'exact controlled Guard/proof environment')
else:
require(row['controlled_environment'] == {}, 'fresh import environment')
if phase == 'parse':
require(len(re.findall(r'(?m)^FULL_DEPENDENCY_PARSE\b', raw)) == 1
and re.findall(r'(?m)^FULL_DEPENDENCY_PARSE checks=([0-9]+) failures=([0-9]+)\n', raw)
== [(str(len(parse_paths)), '0')] and row['actual_parse_checks'] == len(parse_paths), 'unique all-script parse marker')
if test:
parsed = markers.parse_raw(test, raw, contracts, source[test]['sha256'])
require(parsed['passed'] and row['completion'] == parsed
and row['reported_checks'] == parsed['checks']
and row['reported_failures'] == parsed['failures']
and row['completion_marker'] == parsed['marker'] and row['collector_errors'] == [],
'source-pinned raw completion marker')
require(row['source_sha256'] == row['source_sha256_end'] == source[test]['sha256'],
'exact test source before/after')
require(row['policy'] == p, 'current exact suite policy')
if ordinary:
proof = owners.ordinary_gate(confined(out, phase + '-teardown'), pid, out,
p['ordinary_control_labels'])
require(proof == relocated(row['ordinary_owner_pairs']), 'full ordinary/audio ownership proof')
if bounded:
proof = audio.teardown_gate(confined(out, phase + '-teardown'), pid, out)
require(proof == relocated(row['old_bounded_proof']), 'full bounded audio teardown proof')
require(('ordinary_owner_pairs' in row) == ordinary and ('old_bounded_proof' in row) == bounded
and any(name.startswith(phase + '-teardown/') for name in files) == (ordinary or bounded),
'teardown proofs exactly for the classified suites')
if test == RESTART[0]:
require(row['generated_reports'] == {HANDOFF: dict(handoff, actual_engine_pid=pid,
producer_test=test, producer_source_sha256=source[test]['sha256'], payload_modified=False)},
'the writer generates exactly the retained restart handoff')
require(re.findall(WRITER_LINE, raw) == [(str(pid), handoff['sha256'])]
and read_json(confined(out, 'stage/' + HANDOFF))['writer_pid'] == pid,
'the writer engine printed and recorded its own handoff')
writer_pid = pid
else:
require(row['generated_reports'] == {}, 'only the restart writer generates a report')
if test == RESTART[1]:
require(re.findall(READER_LINE, raw) == [(str(pid), str(writer_pid), handoff['sha256'])]
and pid != writer_pid, 'a separate reader engine read the exact writer handoff')
require(end['actual_captured_PIDs'] == pids, 'all captured engines')
outer_cleanup(end['final_owned_cleanup'], pids)
require(all(end[k] == v for k, v in shared.totals(tests, SUITES).items()), 'recomputed exact totals')
require(shared.inventory(confined(out, 'stage'), stage=True) == dict(source, **{HANDOFF: handoff}),
'entire retained source/settings and handoff exact')
for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items():
require(text('bootstrap/' + name).encode() == pin['text'].encode()
and files['bootstrap/' + name]['sha256'] == pin['sha256'], 'unchanged bootstrap/Guard pins')
require(inventory(out) == dict(files, **{'receipt.json': receipt_pin}),
'whole raw evidence unchanged during verification')
require(content_map(shared.source_refs(shared.HERE.parent)) == source,
'whole current source unchanged during verification')
print(json.dumps({'verified': True, 'actual_suites': len(SUITES), 'parse_checks': len(parse_paths),
'actual_checks': end['actual_all_roster_checks'], 'engines_started': 0,
'formal_acceptance': False, 'original_unit_complete': False}))
return 0
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
# No argparse abbreviation, repeated flags, = aliases or positional tokens.
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument('--output', required=True)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--collect', action='store_true', help='Root only; fresh native collection')
modes.add_argument('--prepare-only', action='store_true')
seen = set()
i = 0
while i < len(argv):
flag = argv[i]
require(flag in ('--output', '--collect', '--prepare-only', '--help')
and flag not in seen, 'unknown, duplicate or malformed flag')
seen.add(flag)
if flag == '--output':
i += 1
require(i < len(argv) and argv[i] and not argv[i].startswith('--'), 'output value required')
i += 1
args = parser.parse_args(argv)
args.timeout = 1200
root = shared.HERE.parent
output = args.output
# The registered validation passes the literal '{build_dir}/p07-weather-evidence'. The current Runner
# replaces only {python} in validation argv and gives the task's build directory as BUILD_OUTPUT_DIR.
if output.startswith('{build_dir}/'):
build = os.environ.get('BUILD_OUTPUT_DIR', '')
require(build and Path(build).is_absolute() and Path(build).is_dir() and not Path(build).is_symlink(),
'registered build directory from BUILD_OUTPUT_DIR')
output = build + output[len('{build_dir}'):]
out = plain_path(output)
refs = shared.source_refs(root)
require(set(SUITES).issubset(shared.roster(root)), 'normal discovery includes narrow roster and restart tests')
discovered = shared.roster(root)
require(discovered.index(RESTART[0]) < discovered.index(RESTART[1]), 'the full roster also runs the writer first')
markers = shared.module('p07_exact_markers', shared.HERE / 'strict_test_markers.py')
contract_path = root / 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json'
contract_sha = shared.sha(contract_path)
contracts = markers.load_contracts(contract_path)
require(all(name in contracts for name in SUITES), 'complete marker roster')
policy = shared.read(shared.SUPPORT / 'suite-policy.json')
shared.policy_coverage(SUITES, refs, policy)
for test in SUITES:
shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies'])
require(test not in policy['outside_routes'], 'no outside report routes in the narrow run')
require(policy['fixed_generated_report_routes'].get(test, []) == ([HANDOFF] if test == RESTART[0] else []),
'only the restart writer declares one generated report')
for test in ROSTER[:1] + RESTART:
require(contracts[test]['source_sha256_at_schema_audit'] == refs[test]['sha256'],
'new strict source-hashed marker')
parse_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')]
if args.prepare_only:
ancestor = out.parent
while not ancestor.exists():
ancestor = ancestor.parent
free = shutil.disk_usage(ancestor).free
size = sum(p['bytes'] for p in refs.values())
print(json.dumps({'roster': ROSTER, 'source_files': len(refs), 'source_bytes': size,
'free_bytes': free, 'conservative_required_bytes': size + shared.MIN_SPACE,
'capacity_available': free >= size + shared.MIN_SPACE, 'parse_paths': len(parse_paths),
'restart_phases': RESTART, 'future_native_phases': len(SUITES) + 2, 'actual_checks': None, 'engines': 0, 'stages': 0,
'formal_acceptance': False}))
return 0
if args.collect:
require(not out.exists() and out.parent.is_dir(), 'fresh output with existing parent required')
require(not out.is_relative_to(root), 'private collection outside source tree')
return collect(args, root, out, refs, markers, contracts, policy,
contract_path, contract_sha, parse_paths)
return verify(out, refs, markers, contracts, policy, contract_sha, parse_paths)
if __name__ == '__main__':
try:
raise SystemExit(main())
except (AssertionError, OSError, ValueError, KeyError, TypeError) as error:
print(type(error).__name__ + ': ' + str(error), file=sys.stderr)
raise SystemExit(1)