Merge commit 'db87270972c9b243e3a1fccdf2c1fa96975dffc1' into HEAD

This commit is contained in:
Agent Control Runner
2026-10-24 10:25:30 +09:00
27 changed files with 2290 additions and 30 deletions
+91 -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 UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10
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 TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8
const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7
@@ -85,6 +86,7 @@ const RNG_STREAM_IDS: Array[StringName] = [
&"defense",
&"event",
&"loot",
&"weather",
]
const DEFENSE_COMMAND_TYPES: Array[StringName] = [
&"create_defense_raid",
@@ -463,6 +465,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
if not report[&"valid"]:
_clock.set_paused(true)
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)
return report
@@ -1291,6 +1295,18 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
return false
elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute):
return false
# A13: the saved day's draw must be the draw rule's own result (SPEC section 5.3).
if not NaturalWeather.is_consistent_draw(restored_weather.get_today_draw()):
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()
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"]):
@@ -1323,6 +1339,12 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
if has_defense_snapshot:
return false
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:
for required_stream_id: StringName in RNG_STREAM_IDS:
if not restored_streams.has(required_stream_id):
@@ -2951,6 +2973,8 @@ func _operations_arrival_boundary(
&"ok": not captured.is_empty(),
&"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,
# A13: rain halves the planned new arrivals of this boundary.
&"arrival_percent": NaturalWeather.arrival_percent(weather.get_kind()),
}
if phase == OperationsCoordinator.PHASE_VALIDATE:
return {
@@ -8784,6 +8808,7 @@ func _advance_guest_vitals(absolute_minute: int) -> void:
&"is_night": minute_of_day < 360 or minute_of_day >= 1_080,
&"intimacy_day": 3,
&"intimacy_night": 7,
&"mana_fog": weather.get_kind() == &"mana_fog",
}
})
@@ -9035,7 +9060,7 @@ func _advance_guest_ai_decisions(absolute_minute: int) -> void:
GuestRuntime.State.ENTERING:
guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED")
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")
continue
guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id))
@@ -10993,13 +11018,14 @@ func _reservation_references_are_valid(
## HUD view of the A06 reserve: supply, consumption and storage per interval.
func get_mana_reserve_report() -> Dictionary:
var demand := _mana_reserve_demand()
var generation := NaturalWeather.mana_yield(ManaReserveCycle.WORLD_TREE_GENERATION, weather.get_kind())
return {
&"amount": int(_state.get_resource(&"mana")),
&"capacity": ManaReserveCycle.CAPACITY,
&"generation": ManaReserveCycle.WORLD_TREE_GENERATION,
&"generation": generation,
&"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES,
&"demand": demand,
&"net": ManaReserveCycle.WORLD_TREE_GENERATION - demand,
&"net": generation - demand,
&"last_flow": mana_reserve.get_last_flow(),
}
@@ -11032,8 +11058,10 @@ func _advance_mana(absolute_minute: int) -> void:
return
# A06: generation and upkeep demand settle before local distribution and the
# 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(
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)):
_latch_infrastructure_failure(
@@ -12392,6 +12420,8 @@ func _on_guest_despawned(guest_id: StringName, persistent_record: Dictionary) ->
elevators.cancel_actor(actor_id)
var path_removed := path_service.despawn_actor(actor_id)
_movement_credits.erase(actor_id)
# A13: a rain hold describes a present guest, so it leaves with the guest.
weather.release_rain_hold(guest_id)
_synchronize_operations_population()
var payload := persistent_record.duplicate(true)
payload[&"path_actor_id"] = actor_id
@@ -13572,10 +13602,66 @@ func get_staff_rest_report(staff_id: StringName) -> Dictionary:
func _advance_weather(absolute_minute: int) -> void:
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:
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.
@@ -3958,6 +3958,39 @@
"path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md",
"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": "e34b52ba3511f7391de1435c2f907997dd10235742ebc029831808af969c68e0",
"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": "d85e56dcf75535b19b1af615f8b75e010f4f3906a0e71afdf97102e1bfbffc65",
"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": "e5c2376e9cb3197d6a0a8d99c9dea020cad2648328178949cd188d999e72071a",
"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",
@@ -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,18 @@
{
"task": "P07-01-WX",
"spec": "docs/plans/original-completion-2026-10-02/execution/p07-01/wx/SPEC.md",
"rule": "SPEC WX8: every changed expectation of an existing suite is listed with the value before and after and the reason. The four changes carried from the original task stay listed in ../EXPECTATION_CHANGES.json (byte for byte); this file lists the changes made in P07-01-WX.",
"changes": [
{
"suite": "tests/business/test_required_kitchen.gd",
"check": "whole typed loaded state unchanged except complete identity/saveID-bound IO1: legacy14-load (and the byte-identical-copy legacy migration control, which uses the same mapped expectation)",
"before": "_approved_runtime_migrations maps A06 (mana reserve fresh start), P14-03 (elevators v1 to v2) and P05-02 A12 (empty fund accounts) into the old checkpoint before the whole typed comparison",
"after": "it also maps the P07-01 A13 migration, each part checked against its own contract before it is mapped: the weather snapshot v1 to v2 gains only the natural state (last_draw_day counts every day whose 06:00 has passed, today empty) and empty rain_holds, with minute, kind, since and pending unchanged; rng_streams gains only the weather stream from the world seed, every other stream byte-identical",
"reason": "A13 (SPEC section 5.2) migrates a save from before natural weather on load. The genuine legacy14 fixture is such a save, so its loaded state differs from the old checkpoint exactly in these two fields. Found by the S c501360 full roster (phase 003, 163 checks, 2 failures) and the independent review finding 1."
}
],
"found_by": {
"full_roster": "FULL_ROSTER_C501360_FAILURE_R01.json",
"review": "p07-01-wx-independent-review-c501360-r01/DESKTOP_RESULT.json (sha256 aea0cc4f2c00a6a093ca256154c62812a4d83749d075c6a4a9d36c1270026da7)"
}
}
@@ -0,0 +1,123 @@
# 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` のとおり。
- 出来事の一覧の表示: 毎日 06:00 の出来事 `WEATHER_FORECAST_DRAWN` には、`scripts/ui/event_log_dialog.gd` の `EVENT_NAMES` に日本語の名前が無い。そのため、プレイヤーが見る出来事の一覧では「未識別の記録」と出る。
- このファイルは契約の `allowed_paths` の外なので、この作業では直さない。
- 名前を足す作業は、別の単位として提案する。
- 照合の ordinary の分岐: 狭い roster に `ordinary_cleanup_prepared` の suite が無い。そのため、照合の ordinary の分岐は、この validation では実地には動かない。
- 実地に動くのは、old_bounded(000・011)と、後片付けの無い段だけ。
- ordinary の分岐は、共通の収集と同じ環境と証明の照合を、コードの上で求めている。
## 6. 直し 1(S `c5013601` の独立レビューと全 roster の後。repair 1)
- 契機:
- S `c5013601` の全 roster が、段 003 の `tests/business/test_required_kitchen.gd` で止まった(`FULL_ROSTER_C501360_FAILURE_R01.json`)。
- 独立レビュー(`local_21295ab0`)は changes_requested で、high 1・medium 1・low 4。
- 管理の記録は `COMMANDER_REPAIR_PLAN_P07_01_WX_R01.md`。
- 直したもの:
1. high: kitchen の試験の `_approved_runtime_migrations` に、A13 の移行を足した。2つの部分を、それぞれの契約で照合してから写す(`./EXPECTATION_CHANGES.json`)。
- 天候 v1→v2 では、`natural` と空の `rain_holds` だけが足される。
- `rng_streams` には、`world_seed` から作った `weather` だけが足される。
2. medium: 再開の台本に霧の場面を足した(`tests/helpers/p07_weather_restart_fixture.gd`、writer、reader)。
- seed 1592(0 日目 06:00〜10:00 がマナ霧)を使う。
- 06:58 と 09:58 に成人確認済みの客が着く。この世界には施設が無いので、客は数分で帰る。そのため、保存の時刻に館内に居るように着く時刻を選んだ。
- 保存は 419・420・421・599・600・601 分。
- 比べる状態に、客の `needs`(性欲)と、マナの貯蔵の snapshot を足した。
- writer は、straight の進行で次を確かめる。
- 成人の客の性欲が、霧の間は1分に4、霧の後は3上がる。
- 10 分ごとのマナの境界が、霧の間は 24、霧の後は 20。
- writer は、同じ process で各保存ファイルを読み直す。読んだ直後と1分後、最後の状態が、straight と bytes で一致することを確かめる。
- reader は、別の process で同じことを確かめる。
- これで、霧の産出と性欲の効果が二重に効かないことを示す。
3. low(試験の不足):
- (a) 霧の間に帰りの時刻になった客は延びない。
- (b) 延びた客は、10:02 の決定(`_advance_guest_ai_decisions`)で PLANNED_EXIT で帰り始める。施設の無いこの世界では、客は延長の前に次の決定で施設が無いので帰る。そのため writer の雨の場面では、hold の記録の後も今の規則どおり(`NO_SUITABLE_FACILITY`)帰ることを、厳密に確かめた。元の「or has left since」は外した。
- (c) 上部の表示で、嵐とマナ霧の名前・予報・効果の案内を確かめる。霧の案内が炉を主張しないことも確かめる。
4. low(§5.3 の細部): 製品の `scripts/environment/natural_weather_policy.gd` に `is_consistent_draw` を足した。
- 保存されたその日の抽選について、roll と種類、slot と開始、終わりと続く時間の対応を照合する。
- Runtime の復元(`autoload/simulation_runtime.gd`)は、矛盾した記録を拒否し、状態を保つ。
- 拒否の試験を3つ足した。
5. 開示: 上の §5 の2つ。
- 比べる状態の `needs` は、名前を文字の順に並べる。読み直した客は needs のキーの順が違うことがあり、`StringName` の並べ替えは文字の順ではなく、process ごとに違うため。
- 固定の値: 変えた3本(`test_p07_weather_runtime.gd`・writer・reader)の source の SHA を、`tools/regression/suite-policy.json` と `completion_marker_contracts.json` で更新した。
- 確かめ(局所。正式の証拠ではない): scratch の stage で、変えた suite と狭い roster の 12 本、kitchen を直接動かして合格した(`P07_01_WX_REPAIR1_LOCAL_CHECKS_R01.json`)。正式の証拠は、新しい S の native の収集、登録の validation、全 roster、独立レビュー。
## 7. 直し 2(S `f99c881b` の最終の独立レビューの後。repair 2)
- 契機:
- S `f99c881b` の最終の独立レビュー(`local_c4c1a8d6`)は changes_requested(medium 1・low 1)。前のレビューの high と medium は解消したと判定された。
- S `f99c881b` の全 roster は合格した(198 本・22,857 checks)。
- 管理の記録は `COMMANDER_REPAIR_PLAN_P07_01_WX_R02.md`。
- 診断(scratch の stage)で分かったこと:
- 施設の無い試験の世界では、延びた客は 06:03 に帰り始め(`NO_SUITABLE_FACILITY`)、06:04 に居なくなる。必要(needs)の値や客の種類を変えても同じ。
- そのため、再開の保存には延長の記録が一度も乗っていなかった。
- 製品の不具合(持ち越したコード): 延長の記録は、次の tick の天候の境界まで片づかなかった。
- そのため、客が居なくなった分の保存は、保存は成功するが読み込めなかった(`RESTORE_FAILED`)。
- 原因は、復元の照合「延長は館内の客の予定と一致すること」。
- 直したもの:
1. 製品: 客が居なくなった時(`autoload/simulation_runtime.gd` の `_on_guest_despawned`)に、その客の延長の記録をすぐ片づける。
- `scripts/environment/weather_timeline.gd` に `release_rain_hold` を足した。
- 天候の境界での片づけは今のまま残した。
2. medium(WX7): 再開の台本の雨の場面に、2つの保存を足した。
- 363 分: 延びた客が帰る途中で館内に居て、延長の記録がある。
- 364 分: 客が居なくなった直後で、延長の記録が無い。上の不具合の回帰の確かめ。
- writer は、雨の全ての保存を同じ process で読み直す。読んだ直後・1分後・最後の状態が、straight と bytes で一致することを確かめる。
- reader は、別の process で同じことを確かめる。
- 363 分の保存では、延長の記録が1つで、`planned_exit_at` が 602 であることを厳密に確かめる。1分後の一致で、二度目の延長が無いことを示す。
- 500 分の確かめから、誤っていた「with the hold of a present guest」を外した。
3. 回帰: `test_p07_weather_runtime.gd` で、通常の進行で延びた客が帰った分に、延長の記録が片づいていて、その分の snapshot が復元できることを確かめる。
- 開示(U1): 施設の無い試験の世界では、延びた客は延長の時刻より前に、施設が無いので帰る。
- そのため、通常の時刻の進行で延長の時刻(10:02)に着いて帰る場面は、この試験では作れない。施設を建てて客が使う場面が要る。
- 延長の時刻の帰りは、決定の処理を延長の時刻でじかに呼んで確かめている(`_test_rain_hold_exit`)。延長の時刻に、二度目の延長をせず、PLANNED_EXIT で帰り始める。
- 固定の値: 変えた3本の source の SHA を、`tools/regression/suite-policy.json` と `completion_marker_contracts.json` で更新した。
- 確かめ(局所。正式の証拠ではない): scratch の stage で、狭い roster の 12 本・再開の2段・kitchen を直接動かして合格した(`P07_01_WX_REPAIR2_LOCAL_CHECKS_R01.json`)。
+16
View File
@@ -136,6 +136,7 @@ var rank_button: Button
var rank_dialog: ConfirmationDialog
var event_log_dialog: AcceptDialog
var weather_label: Label
var weather_forecast_label: Label
var guest_count_label: Button
var dimension_migration_dialog: ConfirmationDialog
var _dimension_migration_digest := ""
@@ -467,6 +468,11 @@ func _create_top_bar() -> Control:
weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。"
weather_label.mouse_filter = Control.MOUSE_FILTER_STOP
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.text = "来客 0人"
@@ -1550,6 +1556,16 @@ func _refresh_dashboard() -> void:
var weather_report: Dictionary = _runtime.get_weather_report()
var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"}
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 queued_guests := 0
var served_guests := 0
@@ -0,0 +1,92 @@
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])}
## A saved day's draw must be this rule's own result (SPEC section 5.3): the roll
## picks the kind, a clear day has no slot, and any other day starts on its slot
## and lasts its kind's duration. An empty draw (no day drawn yet) is consistent.
static func is_consistent_draw(draw: Dictionary) -> bool:
if draw.is_empty():
return true
var roll := int(draw[&"roll"])
var kind := StringName(draw[&"kind"])
var expected: StringName = &"clear"
if roll >= FOG_BELOW:
expected = &"storm"
elif roll >= RAIN_BELOW:
expected = &"mana_fog"
elif roll >= CLEAR_BELOW:
expected = &"rain"
if kind != expected:
return false
if kind == &"clear":
return int(draw[&"slot"]) == -1 and int(draw[&"start"]) == -1 and int(draw[&"end"]) == -1
var slot := int(draw[&"slot"])
var start := int(draw[&"day"]) * DAY_MINUTES + FIRST_START_MINUTE_OF_DAY + slot * START_SLOT_MINUTES
return slot >= 0 and slot < START_SLOTS and int(draw[&"start"]) == start \
and int(draw[&"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
+169 -4
View File
@@ -2,14 +2,25 @@ extends RefCounted
## Weather state and scheduled transitions. The caller owns generation policy;
## 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 MAX_MINUTE := 2147483647
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 _kind: StringName = &""
var _since := -1
var _pending: Array[Dictionary] = []
var _last_draw_day := -1
var _today_draw: Dictionary = {}
var _rain_holds: Dictionary = {}
func initialize(kind: Variant, minute: Variant) -> bool:
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
_since = minute
_kind = StringName(kind)
_last_draw_day = _days_before(minute)
return true
func get_kind() -> StringName:
@@ -25,6 +37,9 @@ func get_kind() -> StringName:
func get_minute() -> int:
return _minute
func get_since() -> int:
return _since
func permits_new_arrivals() -> bool:
return _minute >= 0 and _kind != &"storm"
@@ -55,13 +70,100 @@ func advance_to(minute: Variant) -> Dictionary:
_minute = minute
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)
## The hold of a guest that has just left; a save in that same minute then
## holds only present guests' holds (P07-01 SPEC section 5).
func release_rain_hold(guest_id: StringName) -> void:
_rain_holds.erase(String(guest_id))
func create_snapshot() -> Dictionary:
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:
if not _keys(source, ["version", "minute", "kind", "since", "pending"]) \
or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \
var version: Variant = source.get("version", source.get(&"version", null))
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 source["since"] > source["minute"] or not _valid_kind(source["kind"]) \
or not source["pending"] is Array or source["pending"].size() > MAX_PENDING:
@@ -75,12 +177,75 @@ func restore_snapshot(source: Dictionary) -> bool:
return false
previous = 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"]
_kind = StringName(source["kind"])
_since = source["since"]
_pending = pending
_last_draw_day = last_draw_day
_today_draw = today
_rain_holds = holds
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:
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_leisure += int(normalized_effects.get(&"leisure", 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_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)
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
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:
return "%s must be boolean." % field_id
if _has_value(context, &"need_effects"):
+20 -13
View File
@@ -11,6 +11,10 @@ const CAPACITY := 300
const WORLD_TREE_GENERATION := 20
const INTERVAL_MINUTES := 10
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 FLOW_KEYS: Array[String] = [
"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
## processed are no-ops, so per-minute callers and same-minute retries are safe.
## Mana may rise between boundaries (event refunds, commands); anything above
## capacity is discarded and recorded as overflow at the next boundary.
func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary:
## capacity is discarded and recorded as overflow at the next boundary. The
## 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:
return _failure(&"NOT_STARTED")
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")
var boundary: int = minute - minute % INTERVAL_MINUTES
if boundary <= _minute:
@@ -49,7 +55,7 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary
var flow := {}
var count := 0
for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES):
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute)
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute, generation)
amount = flow[&"after"]
count += 1
_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)}
## Furnace offers are judged after natural generation and upkeep demand are
## paid: base_after = min(C, max(0, B + G - D)); accept only with 100 free.
static func furnace_offer_accepted(before: int, demand: int) -> bool:
var base_after := mini(CAPACITY, maxi(0, before + WORLD_TREE_GENERATION - demand))
return CAPACITY - base_after >= FURNACE_YIELD
## paid: base_after = min(C, max(0, B + G - D)); accept only with the yield free
## (100, or 120 at an A13 fog boundary where G is 24).
static func furnace_offer_accepted(before: int, demand: int, generation: int = WORLD_TREE_GENERATION, furnace_yield: int = FURNACE_YIELD) -> bool:
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:
var available := before + WORLD_TREE_GENERATION + furnace
static func plan(before: int, demand: int, furnace: int, start: int, end: int, generation: int = WORLD_TREE_GENERATION) -> Dictionary:
var available := before + generation + furnace
var consumed := mini(available, demand)
var remaining := available - consumed
return {&"from_minute": start, &"to_minute": end, &"before": before,
&"generation": WORLD_TREE_GENERATION, &"furnace": furnace, &"demand": demand,
&"generation": generation, &"furnace": furnace, &"demand": demand,
&"consumed": consumed, &"shortage": demand - consumed,
&"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)}
@@ -105,10 +112,10 @@ func restore_snapshot(source: Variant) -> bool:
if not _amount(flow[key]):
return false
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"]:
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):
return false
_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"
)
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(
mini(local_capacity, external_capacity),
int(_config[&"max_arrivals_per_interval"])
@@ -1022,6 +1031,8 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
var plans: Array[Dictionary] = []
var total_count := 0
var capped_rows: Array[Dictionary] = []
var accepted_by_row: Array[int] = []
var planned_total := 0
for row: Dictionary in intent_rows:
var accepted := mini(int(row[&"desired_count"]), remaining)
var key: String = row[&"key"]
@@ -1034,6 +1045,21 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
&"guest_class": String(row[&"guest_class"]),
&"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:
continue
if plan_sequence > MAX_SEQUENCE_VALUE \
@@ -1058,7 +1084,6 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
})
plan_sequence += 1
total_count += accepted
remaining -= accepted
var payload := capture_payload.duplicate(true)
payload[&"plans"] = plans
+32 -2
View File
@@ -276,8 +276,9 @@ func _legacy_controls(writer: Dictionary, reader: Dictionary) -> void:
## Approved later save migrations applied when an older save is loaded, each
## checked against its own contract and then mapped into `expected` in place:
## A06 starts a fresh finite Mana reserve at the restored boundary, and P14-03
## raises the elevator snapshot from v1 to v2 with every other field unchanged.
## A06 starts a fresh finite Mana reserve at the restored boundary, P14-03
## raises the elevator snapshot from v1 to v2 with every other field unchanged,
## and P07-01 A13 migrates the weather snapshot and adds the weather stream.
func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> Dictionary:
var old: Dictionary = expected[&"systems"][&"runtime"]
var now: Dictionary = reader[&"systems"][&"runtime"]
@@ -322,6 +323,35 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
old[&"economy"] = economy
fields.append("economy_A12_empty_fund_accounts")
proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added}
# P07-01 A13 (SPEC section 5.2): a save from before natural weather keeps its
# weather and pending schedule, counts every day whose 06:00 has passed as
# drawn (no past draw), holds no rain extension, and starts the weather
# stream from the world seed; every other field stays byte-identical.
var old_weather: Variant = old.get(&"weather")
var now_weather: Variant = now.get(&"weather")
if old_weather is Dictionary and int(old_weather.get(&"version", 0)) == 1 and now_weather is Dictionary and int(now_weather.get(&"version", 0)) == 2:
var weather_minute := int(old_weather[&"minute"])
var last_draw_day := floori(weather_minute / 1440.0) - (0 if posmod(weather_minute, 1440) > 360 else 1)
var migrated: Dictionary = (old_weather as Dictionary).duplicate(true)
migrated[&"version"] = 2
migrated[&"natural"] = {&"last_draw_day": last_draw_day, &"today": {}}
migrated[&"rain_holds"] = {}
var only_natural_state_added: bool = migrated == now_weather
ok = ok and only_natural_state_added
old[&"weather"] = (now_weather as Dictionary).duplicate(true)
fields.append("weather_v1_to_v2_A13")
proof["weather_A13"] = {"last_draw_day": last_draw_day, "only_natural_state_added": only_natural_state_added}
var old_streams: Variant = old.get(&"rng_streams")
var now_streams: Variant = now.get(&"rng_streams")
if old_streams is Dictionary and now_streams is Dictionary and not (old_streams as Dictionary).has("weather") and (now_streams as Dictionary).has("weather"):
var seeded: Dictionary = DeterministicRng.from_root_seed(int(old[&"world_seed"]), &"weather").snapshot()
var others: Dictionary = (now_streams as Dictionary).duplicate(true)
others.erase("weather")
var only_weather_stream_added: bool = now_streams["weather"] == seeded and var_to_bytes(others) == var_to_bytes(old_streams)
ok = ok and only_weather_stream_added
old[&"rng_streams"] = (now_streams as Dictionary).duplicate(true)
fields.append("rng_streams_weather_from_world_seed_A13")
proof["rng_streams_A13"] = {"only_weather_stream_added": only_weather_stream_added}
if not fields.is_empty():
# Saves keep the Runtime's own key order, so the mapped tree follows it.
ok = ok and old.size() == now.size()
@@ -0,0 +1,210 @@
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 fog scene (WX7) writes saves around the 07:00 fog mana boundary and the
## fog end, reloads each one in this process and continues it, and hands those
## packages to the reader too.
## 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"]
var held_guest: Variant = straight_runtime.guest_service.get_guest(Fixture.HELD_GUEST)
var held := {"hold": {}, "reason": &"", "after": {}}
fixtures.play(straight, Fixture.FIRST_MINUTE, Fixture.LAST_MINUTE, func(minute: int) -> void:
if (held["hold"] as Dictionary).is_empty():
held["hold"] = straight_runtime.weather.get_rain_hold(Fixture.HELD_GUEST)
if StringName(held["reason"]).is_empty():
held["reason"] = held_guest.exit_reason
if minute == 363:
_expect(straight_runtime.guest_service.get_guest(Fixture.HELD_GUEST) != null and held_guest.state == GuestRuntime.State.LEAVING
and straight_runtime.weather.get_rain_hold(Fixture.HELD_GUEST) == Fixture.HOLD, "at 06:03 the held guest is still present and leaving with its hold")
if minute == 364:
_expect(straight_runtime.guest_service.get_guest(Fixture.HELD_GUEST) == null and straight_runtime.weather.get_rain_holds().is_empty(),
"at 06:04 the held guest is gone and its hold left with it")
if minute - 1 in Fixture.SAVE_MINUTES:
held["after"][minute] = fixtures.state_bytes(straight_runtime))
# The hold only moves the planned exit; every other current rule still applies,
# so in this world without facilities the guest leaves for lack of one.
_expect(held["hold"] == Fixture.HOLD and held_guest.planned_exit_at == 602 and held["reason"] == &"NO_SUITABLE_FACILITY",
"the guest due at 06:02 was held to 10:02 and still left by the current rules (%s)" % String(held["reason"]))
_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 == 363:
_expect(saving_runtime.guest_service.get_guest(Fixture.HELD_GUEST) != null and saving_runtime.weather.get_rain_holds() == {String(Fixture.HELD_GUEST): Fixture.HOLD}
and saving_runtime.guest_service.get_guest(Fixture.HELD_GUEST).planned_exit_at == 602, "minute 363 saves the hold of the present held guest")
if minute == 364:
_expect(saving_runtime.guest_service.get_guest(Fixture.HELD_GUEST) == null and saving_runtime.weather.get_rain_holds().is_empty(), "minute 364 saves just after the held guest left, without its hold")
if minute == 500:
_expect(saving_runtime.weather.get_kind() == &"rain", "minute 500 saves mid-rain")
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 final_state := fixtures.state_bytes(straight_runtime)
for minute: int in Fixture.SAVE_MINUTES:
var reload := fixtures.new_fixture(false)
var reload_runtime: Variant = reload[&"runtime"]
_expect(bool(reload_runtime.load_from_path(str(saved_paths.get(minute, Fixture.WRITER_SAVE % minute))).get(&"ok", false))
and fixtures.state_bytes(reload_runtime) == saved_states.get(minute, PackedByteArray()) and reload[&"clock"].absolute_minute == minute,
"this process reloads the save file of minute %d exactly" % minute)
if minute == 363:
_expect(reload_runtime.weather.get_rain_holds() == {String(Fixture.HELD_GUEST): Fixture.HOLD} and reload_runtime.guest_service.get_guest(Fixture.HELD_GUEST).planned_exit_at == 602,
"the reloaded minute 363 save keeps the one hold and the extended plan")
fixtures.play(reload, minute, minute + 1, Callable())
_expect(fixtures.state_bytes(reload_runtime) == held["after"].get(minute + 1, PackedByteArray()),
"one minute after the minute %d reload equals the uninterrupted run (no second extension)" % minute)
fixtures.play(reload, minute + 1, Fixture.LAST_MINUTE, Callable())
_expect(fixtures.state_bytes(reload_runtime) == final_state, "the minute %d reload continues to the uninterrupted final state" % minute)
fixtures.free_fixture(reload)
var saves := _packages(Fixture.SAVE_MINUTES, saved_paths, saved_states, Fixture.WRITER_SAVE)
var after := {}
for minute: int in held["after"]:
var bytes: PackedByteArray = held["after"][minute]
after[str(minute)] = {"sha256": Fixture.sha256(bytes), "base64": Marshalls.raw_to_base64(bytes)}
var fog := _fog_scene(fixtures)
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),
"after_states": after,
"saves": saves,
"fog": fog,
}
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)
## WX7 fog scene: the uninterrupted run, the run that writes the save files, and
## a same-process reload of every save file continued to the end.
func _fog_scene(fixtures: Variant) -> Dictionary:
var straight: Dictionary = fixtures.new_fixture(false, Fixture.FOG_SEED)
var runtime: Variant = straight[&"runtime"]
_expect(runtime.weather.get_kind() == &"mana_fog" and int(runtime.weather.get_today_draw().get("end", -1)) == 600, "seed 1592 opens the fog scene with mana fog 06:00-10:00")
var seen := {"intimacy": {}, "rises": 0, "fog_rises": 0, "rises_ok": true, "flows": 0, "fog_flows": 0, "flows_ok": true, "after": {}}
fixtures.play(straight, Fixture.FIRST_MINUTE, Fixture.FOG_LAST_MINUTE, func(minute: int) -> void:
var fog_now: bool = runtime.weather.get_kind() == &"mana_fog"
var last: Dictionary = seen["intimacy"]
for guest_id: StringName in Fixture.FOG_ADULTS.values():
var guest: Variant = runtime.guest_service.get_guest(guest_id)
if guest == null:
continue
var now := int(guest.needs[&"intimacy"])
if last.has(guest_id) and now < 100:
seen["rises"] += 1
seen["rises_ok"] = seen["rises_ok"] and now - int(last[guest_id]) == (4 if fog_now else 3)
if fog_now:
seen["fog_rises"] += 1
last[guest_id] = now
if minute % 10 == 0:
seen["flows"] += 1
seen["flows_ok"] = seen["flows_ok"] and int(runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == (24 if fog_now else 20)
if fog_now:
seen["fog_flows"] += 1
if minute - 1 in Fixture.FOG_SAVE_MINUTES:
seen["after"][minute] = fixtures.state_bytes(runtime))
_expect(seen["rises_ok"] and int(seen["fog_rises"]) >= 3 and int(seen["rises"]) - int(seen["fog_rises"]) >= 2,
"adult-verified intimacy rises by 4 a minute in fog and 3 after it (%d rises, %d in fog)" % [int(seen["rises"]), int(seen["fog_rises"])])
_expect(seen["flows_ok"] and int(seen["fog_flows"]) >= 20 and int(seen["flows"]) > int(seen["fog_flows"]),
"each 10-minute mana boundary adds 24 in fog and 20 after it (%d boundaries, %d in fog)" % [int(seen["flows"]), int(seen["fog_flows"])])
var saving: Dictionary = fixtures.new_fixture(false, Fixture.FOG_SEED)
var saving_runtime: Variant = saving[&"runtime"]
var states := {}
var paths := {}
fixtures.play(saving, Fixture.FIRST_MINUTE, Fixture.FOG_LAST_MINUTE, func(minute: int) -> void:
if minute in Fixture.FOG_SAVE_MINUTES:
var path := Fixture.FOG_WRITER_SAVE % minute
states[minute] = fixtures.state_bytes(saving_runtime)
_expect(bool(saving_runtime.save_to_path(path).get(&"ok", false)), "fog save file at minute %d" % minute)
paths[minute] = path)
var final_state: PackedByteArray = fixtures.state_bytes(runtime)
_expect(fixtures.state_bytes(saving_runtime) == final_state, "writing the fog save files leaves the state as without saving")
for minute: int in Fixture.FOG_SAVE_MINUTES:
var reload: Dictionary = fixtures.new_fixture(false, Fixture.FOG_SEED)
var reload_runtime: Variant = reload[&"runtime"]
_expect(bool(reload_runtime.load_from_path(str(paths.get(minute, Fixture.FOG_WRITER_SAVE % minute))).get(&"ok", false))
and fixtures.state_bytes(reload_runtime) == states.get(minute, PackedByteArray()) and reload[&"clock"].absolute_minute == minute,
"this process reloads the fog save file of minute %d exactly" % minute)
fixtures.play(reload, minute, minute + 1, Callable())
_expect(fixtures.state_bytes(reload_runtime) == seen["after"].get(minute + 1, PackedByteArray()),
"one minute after the minute %d fog reload equals the uninterrupted run (the fog yield and intimacy apply once)" % minute)
fixtures.play(reload, minute + 1, Fixture.FOG_LAST_MINUTE, Callable())
_expect(fixtures.state_bytes(reload_runtime) == final_state, "the minute %d fog reload continues to the uninterrupted final state" % minute)
fixtures.free_fixture(reload)
var after := {}
for minute: int in seen["after"]:
var bytes: PackedByteArray = seen["after"][minute]
after[str(minute)] = {"sha256": Fixture.sha256(bytes), "base64": Marshalls.raw_to_base64(bytes)}
var saves := _packages(Fixture.FOG_SAVE_MINUTES, paths, states, Fixture.FOG_WRITER_SAVE)
fixtures.free_fixture(straight)
fixtures.free_fixture(saving)
return {
"seed": Fixture.FOG_SEED,
"last_minute": Fixture.FOG_LAST_MINUTE,
"straight_final_state_sha256": Fixture.sha256(final_state),
"straight_final_state_base64": Marshalls.raw_to_base64(final_state),
"after_states": after,
"saves": saves,
}
## The exact save packages and the compared state at each save minute.
func _packages(minutes: Array[int], paths: Dictionary, states: Dictionary, path_format: String) -> Array:
var saves: Array = []
for minute: int in minutes:
var directory := ProjectSettings.globalize_path(str(paths.get(minute, path_format % minute)))
_expect(DirAccess.dir_exists_absolute(directory) and DirAccess.get_directories_at(directory).is_empty(), "save package %s holds only files" % (path_format % 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 %s has its manifest and world" % (path_format % minute))
var state: PackedByteArray = states.get(minute, PackedByteArray())
saves.append({"minute": minute, "files": files, "state_sha256": Fixture.sha256(state), "state_base64": Marshalls.raw_to_base64(state)})
return saves
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,138 @@
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. The fog scene
## (WX7) packages must also equal the writer's state one minute after each save,
## so the fog mana yield and the adult intimacy apply exactly once across the
## process boundary.
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)
_check_fog(handoff.get("fog", {}))
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")
var after: Dictionary = handoff.get("after_states", {})
for save: Variant in saves:
var minute := int((save as Dictionary).get("minute", -1))
var path := Fixture.READER_SAVE % minute
_expect(_write_package(save, path), "save package at minute %d arrived byte-exact" % minute)
var expected_state := Marshalls.base64_to_raw(str((save as Dictionary).get("state_base64", "")))
var next_row: Dictionary = after.get(str(minute + 1), {})
var next_state := Marshalls.base64_to_raw(str(next_row.get("base64", "")))
_expect(not next_state.is_empty() and Fixture.sha256(next_state) == str(next_row.get("sha256", "")), "the writer's state one minute after %d arrived intact" % minute)
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)
if minute == 363:
_expect(runtime.weather.get_rain_holds() == {String(Fixture.HELD_GUEST): Fixture.HOLD} and runtime.guest_service.get_guest(Fixture.HELD_GUEST) != null
and runtime.guest_service.get_guest(Fixture.HELD_GUEST).planned_exit_at == 602, "the reader resumes the one hold of the present held guest and its extended plan")
# Loading replaces the event stream, so the draw counter subscribes again.
fixture[&"draws"][&"count"] = 0
fixtures.watch_draws(fixture)
fixtures.play(fixture, minute, minute + 1, Callable())
_expect(fixtures.state_bytes(runtime) == next_state, "one minute after the minute %d save the reader equals the writer (no second extension)" % minute)
fixtures.play(fixture, minute + 1, 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 _check_fog(fog: Dictionary) -> void:
var fixtures := Fixture.new(_expect)
_expect(int(fog.get("seed", -1)) == Fixture.FOG_SEED and int(fog.get("last_minute", -1)) == Fixture.FOG_LAST_MINUTE, "same fog scene and minute script")
var final_state := Marshalls.base64_to_raw(str(fog.get("straight_final_state_base64", "")))
_expect(not final_state.is_empty() and Fixture.sha256(final_state) == str(fog.get("straight_final_state_sha256", "")), "the writer's fog final state arrived intact")
var saves: Array = fog.get("saves", [])
_expect(saves.size() == Fixture.FOG_SAVE_MINUTES.size(), "every fog save package was handed off")
var after: Dictionary = fog.get("after_states", {})
for save: Variant in saves:
var minute := int((save as Dictionary).get("minute", -1))
var path := Fixture.FOG_READER_SAVE % minute
_expect(_write_package(save, path), "fog save package at minute %d arrived byte-exact" % minute)
var expected_state := Marshalls.base64_to_raw(str((save as Dictionary).get("state_base64", "")))
var next_row: Dictionary = after.get(str(minute + 1), {})
var next_state := Marshalls.base64_to_raw(str(next_row.get("base64", "")))
_expect(not next_state.is_empty() and Fixture.sha256(next_state) == str(next_row.get("sha256", "")), "the writer's state one minute after %d arrived intact" % minute)
var fixture := fixtures.new_fixture(false, Fixture.FOG_SEED)
var runtime: Variant = fixture[&"runtime"]
_expect(bool(runtime.load_from_path(path).get(&"ok", false)) and fixtures.state_bytes(runtime) == expected_state and fixture[&"clock"].absolute_minute == minute,
"the reader resumes the minute %d fog save exactly" % minute)
fixtures.play(fixture, minute, minute + 1, Callable())
_expect(fixtures.state_bytes(runtime) == next_state, "one minute after the minute %d fog save the reader equals the uninterrupted run (no double fog yield or intimacy)" % minute)
fixtures.play(fixture, minute + 1, Fixture.FOG_LAST_MINUTE, Callable())
_expect(fixtures.state_bytes(runtime) == final_state, "continuing the minute %d fog save reaches the uninterrupted final state" % minute)
fixtures.free_fixture(fixture)
SaveService.delete_save(path)
## Writes one handed-off save package byte for byte; true when every file matches its pin.
func _write_package(save: Dictionary, path: String) -> bool:
var directory := ProjectSettings.globalize_path(path)
DirAccess.make_dir_recursive_absolute(directory)
var files: Dictionary = save.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()
return intact
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,403 @@
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_rain_hold_exit()
_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")
var fog_runtime: Variant = _new_runtime(SEED_FOG)[&"runtime"]
var misty: Variant = _spawn(fog_runtime, &"p07_fog_due_guest", &"traveler", 361)
_expect(fog_runtime.weather.get_kind() == &"mana_fog" and not fog_runtime.call("_hold_guest_for_rain", misty, 361) and misty.planned_exit_at == 361, "no hold in mana fog")
## F3: after its hold the guest leaves by the current rule. A guest without a
## suitable facility leaves at its next decision, so the ordinary decision step
## itself runs at the extended time: it is not held again and begins leaving
## with the ordinary PLANNED_EXIT.
func _test_rain_hold_exit() -> void:
var natural := _new_runtime(SEED_RAIN)
var natural_runtime: Variant = natural[&"runtime"]
var departing: Variant = _spawn(natural_runtime, &"p07_departing_guest", &"traveler", 362)
natural[&"clock"].advance_minutes(3)
_expect(natural_runtime.weather.get_rain_hold(&"p07_departing_guest") == {"original_exit": 362, "extended_to": 602} and departing.state == GuestRuntime.State.LEAVING
and departing.exit_reason == &"NO_SUITABLE_FACILITY", "in ordinary minutes the held guest still leaves by the current rules")
natural[&"clock"].advance_minutes(1)
var departed: Dictionary = natural_runtime.create_snapshot()
_expect(natural_runtime.guest_service.get_guest(&"p07_departing_guest") == null and natural_runtime.weather.get_rain_holds().is_empty()
and natural_runtime.restore_snapshot(departed) and natural_runtime.create_snapshot() == departed, "the hold leaves with the guest, so the minute it left restores")
var rainy := _new_runtime(SEED_RAIN)
var runtime: Variant = rainy[&"runtime"]
var guest: Variant = _spawn(runtime, &"p07_exit_guest", &"traveler", 362)
rainy[&"clock"].advance_minutes(2)
_expect(guest.state == GuestRuntime.State.DECIDING and runtime.weather.get_kind() == &"rain", "the guest decides at 06:02 in rain")
_expect(runtime.call("_hold_guest_for_rain", guest, 362) and guest.planned_exit_at == 602 and runtime.weather.get_rain_hold(&"p07_exit_guest") == {"original_exit": 362, "extended_to": 602}, "the rain holds the guest due at 06:02 to 10:02")
runtime.call("_advance_guest_ai_decisions", 602)
_expect(guest.exit_reason == &"PLANNED_EXIT" and guest.state == GuestRuntime.State.LEAVING and guest.planned_exit_at == 602, "at 10:02 the held guest's decision begins leaving with PLANNED_EXIT (%s)" % String(guest.exit_reason))
## 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")
# The saved rain draw of seed 1106 (roll 60-84, slot 0, 06:00-12:00) changed in one field
# each: a roll of a clear day, a slot that does not match the start, a wrong duration.
var today: Dictionary = saved[&"systems"][&"runtime"][&"weather"][&"natural"][&"today"]
_expect(NaturalWeather.is_consistent_draw(today) and String(today["kind"]) == "rain" and int(today["slot"]) == 0, "the saved opening draw follows the draw rule")
for change: Dictionary in [{"roll": 10}, {"slot": 1}, {"end": 700}]:
var inconsistent := saved.duplicate(true)
var drawn: Dictionary = inconsistent[&"systems"][&"runtime"][&"weather"][&"natural"][&"today"]
for key: String in change:
drawn[key] = change[key]
_expect(not runtime.restore_snapshot(inconsistent) and runtime.create_snapshot() == migrated, "an internally inconsistent saved draw is refused atomically (%s)" % str(change))
## 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 now: int = clock.absolute_minute
_expect(runtime.weather.schedule(&"storm", now + 1)[&"ok"] and runtime.weather.schedule(&"mana_fog", now + 2)[&"ok"], "schedule a storm and then mana fog on the ordinary Runtime")
clock.advance_minutes(1)
main.call("_refresh_dashboard")
report = runtime.get_weather_report()
var fog_minute := posmod(now + 2, 1440)
_expect(weather_label.text == "嵐" and report[&"kind"] == &"storm" and String(report[&"effect_text"]) in weather_label.tooltip_text and "新規来客はありません" in weather_label.tooltip_text, "HUD shows the storm and its guidance")
_expect(forecast_label.text == "次: マナ霧 %02d:%02d" % [fog_minute / 60, fog_minute % 60], "HUD forecast shows the next change to mana fog (%s)" % forecast_label.text)
clock.advance_minutes(1)
main.call("_refresh_dashboard")
report = runtime.get_weather_report()
_expect(weather_label.text == "マナ霧" and report[&"kind"] == &"mana_fog" and String(report[&"effect_text"]) in weather_label.tooltip_text
and "1.2倍" in weather_label.tooltip_text and "1.5倍" in weather_label.tooltip_text and not "炉" in weather_label.tooltip_text, "HUD shows the mana fog and its guidance without a furnace claim")
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(&"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.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)
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[&"systems"][&"runtime"].erase(&"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()
expect(runtime.restore_snapshot(completed) and runtime.create_snapshot() == completed, "completed retry restores exactly")
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(SaveService.delete_save(path), "delete isolated test save")
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"]:
var bad := baseline.duplicate(true); bad.erase(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)
bad.merge(change, true)
expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "malformed or contradictory state rejects atomically")
@@ -0,0 +1,151 @@
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.
## With no facility in this world the held guest starts leaving at 06:03 (still
## present with its hold) and is gone at 06:04 (its hold leaves with it).
## The fog scene (WX7): seed 1592 opens day 0 with mana fog 06:00-10:00, two
## adult-verified guests arrive at 06:58 and 09:58 (with no facility in this
## world a guest leaves after a few minutes, so each one is present at its
## saves), and the saves bracket the 07:00 mana boundary and the fog end.
const HANDOFF_PATH := "res://artifacts/p07-01/restart-handoff.json"
const HANDOFF_SCHEMA := "p07-01-restart-handoff-v2"
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
# The held guest present with its hold, the minute it left, mid-rain, just before and
# just after the rain end, just before and just after the day 1 draw.
const SAVE_MINUTES: Array[int] = [363, 364, 500, 719, 720, 1799, 1800]
const HOLD := {"original_exit": 362, "extended_to": 602}
const LAST_MINUTE := 1830
const DRAW_MINUTES: Array[int] = [1800]
const HELD_GUEST := &"p07_restart_guest"
const FOG_SEED := 1592
const FOG_WRITER_SAVE := "user://p07_restart_write/fog_minute_%d"
const FOG_READER_SAVE := "user://p07_restart_read/fog_minute_%d"
# Just before, at and after the 07:00 mana boundary in fog; just before, at and after the fog end.
const FOG_SAVE_MINUTES: Array[int] = [419, 420, 421, 599, 600, 601]
const FOG_LAST_MINUTE := 630
const FOG_ADULTS := {418: &"p07_restart_fog_adult_a", 598: &"p07_restart_fog_adult_b"}
var _expect: Callable
func _init(expect: Callable) -> void:
_expect = expect
func new_fixture(with_guest := true, seed := SEED) -> 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}, &"seed": seed}
if with_guest and seed == SEED:
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). In the fog
## scene the adult-verified guests arrive at their minutes in every run.
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 fixture[&"seed"] == FOG_SEED and FOG_ADULTS.has(minute):
_spawn_adult(fixture[&"runtime"], FOG_ADULTS[minute])
if on_minute.is_valid():
on_minute.call(minute)
func _spawn_adult(runtime: Variant, guest_id: StringName) -> void:
var result: CommandResult = runtime.submit_command(&"spawn_guest", {
&"guest_id": guest_id,
&"guest_type_id": &"adventurer",
&"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": 2000,
},
}, &"world", guest_id, 10, -1, StringName("p07.restart." + String(guest_id)))
_expect.call(result.is_success(), "the fog scene spawns the adult-verified guest %s" % guest_id)
## The weather-facing state compared across processes: weather and its stream,
## guest plans and needs (the fog intimacy), the whole reserve with its last
## flow (the fog yield) and the arrival totals. Needs are listed in text order of
## their names, because a loaded guest may keep its needs in another key order
## (StringName ordering is not text ordering and differs between processes).
func state_value(runtime: Variant) -> Dictionary:
var plans := {}
var needs := {}
for guest: GuestRuntime in runtime.guest_service.get_guests():
plans[String(guest.guest_id)] = guest.planned_exit_at
var names: Array[String] = []
for name: Variant in guest.needs:
names.append(String(name))
names.sort()
var row := {}
for name: String in names:
row[name] = guest.needs[name]
needs[String(guest.guest_id)] = row
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,
"needs": needs,
"reserve": runtime.mana_reserve.create_snapshot(),
"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,
"new_audio_QA": false
}
,
"tests/environment/test_p07_weather_runtime.gd": {
"source_sha256": "e34b52ba3511f7391de1435c2f907997dd10235742ebc029831808af969c68e0",
"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": "d85e56dcf75535b19b1af615f8b75e010f4f3906a0e71afdf97102e1bfbffc65",
"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": "e5c2376e9cb3197d6a0a8d99c9dea020cad2648328178949cd188d999e72071a",
"ordinary_cleanup_prepared": false,
"ordinary_control_labels": [],
"old_bounded_teardown": false,
"new_audio_QA": false
}
},
"outside_routes": {
"tests/supply/test_normal_auto_purchase_chain.gd": {
@@ -474,6 +496,9 @@
"tests/economy/test_p05_funds_restart_1_write.gd": [
"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": [
"artifacts/upkeep-2026-09-29/preclose-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)