Merge commit 'db87270972c9b243e3a1fccdf2c1fa96975dffc1' into HEAD
This commit is contained in:
@@ -37,6 +37,7 @@ const CleaningBridge = preload("res://scripts/environment/facility_cleaning_brid
|
|||||||
const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd")
|
const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd")
|
||||||
const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10
|
const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10
|
||||||
const WeatherTimeline = preload("res://scripts/environment/weather_timeline.gd")
|
const WeatherTimeline = preload("res://scripts/environment/weather_timeline.gd")
|
||||||
|
const NaturalWeather = preload("res://scripts/environment/natural_weather_policy.gd")
|
||||||
const STAIRS_RUNTIME_SNAPSHOT_VERSION := 9
|
const STAIRS_RUNTIME_SNAPSHOT_VERSION := 9
|
||||||
const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8
|
const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8
|
||||||
const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7
|
const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7
|
||||||
@@ -85,6 +86,7 @@ const RNG_STREAM_IDS: Array[StringName] = [
|
|||||||
&"defense",
|
&"defense",
|
||||||
&"event",
|
&"event",
|
||||||
&"loot",
|
&"loot",
|
||||||
|
&"weather",
|
||||||
]
|
]
|
||||||
const DEFENSE_COMMAND_TYPES: Array[StringName] = [
|
const DEFENSE_COMMAND_TYPES: Array[StringName] = [
|
||||||
&"create_defense_raid",
|
&"create_defense_raid",
|
||||||
@@ -463,6 +465,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
|
|||||||
if not report[&"valid"]:
|
if not report[&"valid"]:
|
||||||
_clock.set_paused(true)
|
_clock.set_paused(true)
|
||||||
definition_validation_failed.emit(report)
|
definition_validation_failed.emit(report)
|
||||||
|
# A13: a new game opening at 06:00 draws its first day once streams exist.
|
||||||
|
_draw_natural_weather_if_due(_clock.absolute_minute)
|
||||||
_publish_snapshot(_clock.absolute_minute)
|
_publish_snapshot(_clock.absolute_minute)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
@@ -1291,6 +1295,18 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
|
|||||||
return false
|
return false
|
||||||
elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute):
|
elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute):
|
||||||
return false
|
return false
|
||||||
|
# A13: 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()
|
var restored_cleanliness := FacilityCleanliness.new()
|
||||||
if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION:
|
if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION:
|
||||||
if not runtime_data.get(&"facility_cleanliness") is Dictionary or not restored_cleanliness.restore_snapshot(runtime_data[&"facility_cleanliness"]):
|
if not runtime_data.get(&"facility_cleanliness") is Dictionary or not restored_cleanliness.restore_snapshot(runtime_data[&"facility_cleanliness"]):
|
||||||
@@ -1323,6 +1339,12 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
|
|||||||
if has_defense_snapshot:
|
if has_defense_snapshot:
|
||||||
return false
|
return false
|
||||||
restored_streams[&"defense"] = DeterministicRng.from_root_seed(restored_world_seed, &"defense")
|
restored_streams[&"defense"] = DeterministicRng.from_root_seed(restored_world_seed, &"defense")
|
||||||
|
# A13 migration: saves before natural weather start the weather stream from
|
||||||
|
# the world seed, as defense did (SPEC section 5.2).
|
||||||
|
if not restored_streams.has(&"weather"):
|
||||||
|
if weather_stream_required:
|
||||||
|
return false
|
||||||
|
restored_streams[&"weather"] = DeterministicRng.from_root_seed(restored_world_seed, &"weather")
|
||||||
if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION:
|
if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION:
|
||||||
for required_stream_id: StringName in RNG_STREAM_IDS:
|
for required_stream_id: StringName in RNG_STREAM_IDS:
|
||||||
if not restored_streams.has(required_stream_id):
|
if not restored_streams.has(required_stream_id):
|
||||||
@@ -2951,6 +2973,8 @@ func _operations_arrival_boundary(
|
|||||||
&"ok": not captured.is_empty(),
|
&"ok": not captured.is_empty(),
|
||||||
&"token": captured,
|
&"token": captured,
|
||||||
&"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0) if int(_state.get_resource(&"mana")) > 0 and weather.permits_new_arrivals() else 0,
|
&"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0) if int(_state.get_resource(&"mana")) > 0 and weather.permits_new_arrivals() else 0,
|
||||||
|
# A13: rain halves the planned new arrivals of this boundary.
|
||||||
|
&"arrival_percent": NaturalWeather.arrival_percent(weather.get_kind()),
|
||||||
}
|
}
|
||||||
if phase == OperationsCoordinator.PHASE_VALIDATE:
|
if phase == OperationsCoordinator.PHASE_VALIDATE:
|
||||||
return {
|
return {
|
||||||
@@ -8784,6 +8808,7 @@ func _advance_guest_vitals(absolute_minute: int) -> void:
|
|||||||
&"is_night": minute_of_day < 360 or minute_of_day >= 1_080,
|
&"is_night": minute_of_day < 360 or minute_of_day >= 1_080,
|
||||||
&"intimacy_day": 3,
|
&"intimacy_day": 3,
|
||||||
&"intimacy_night": 7,
|
&"intimacy_night": 7,
|
||||||
|
&"mana_fog": weather.get_kind() == &"mana_fog",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -9035,7 +9060,7 @@ func _advance_guest_ai_decisions(absolute_minute: int) -> void:
|
|||||||
GuestRuntime.State.ENTERING:
|
GuestRuntime.State.ENTERING:
|
||||||
guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED")
|
guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED")
|
||||||
GuestRuntime.State.DECIDING:
|
GuestRuntime.State.DECIDING:
|
||||||
if guest.planned_exit_at <= absolute_minute:
|
if guest.planned_exit_at <= absolute_minute and not _hold_guest_for_rain(guest, absolute_minute):
|
||||||
guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT")
|
guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT")
|
||||||
continue
|
continue
|
||||||
guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id))
|
guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id))
|
||||||
@@ -10993,13 +11018,14 @@ func _reservation_references_are_valid(
|
|||||||
## HUD view of the A06 reserve: supply, consumption and storage per interval.
|
## HUD view of the A06 reserve: supply, consumption and storage per interval.
|
||||||
func get_mana_reserve_report() -> Dictionary:
|
func get_mana_reserve_report() -> Dictionary:
|
||||||
var demand := _mana_reserve_demand()
|
var demand := _mana_reserve_demand()
|
||||||
|
var generation := NaturalWeather.mana_yield(ManaReserveCycle.WORLD_TREE_GENERATION, weather.get_kind())
|
||||||
return {
|
return {
|
||||||
&"amount": int(_state.get_resource(&"mana")),
|
&"amount": int(_state.get_resource(&"mana")),
|
||||||
&"capacity": ManaReserveCycle.CAPACITY,
|
&"capacity": ManaReserveCycle.CAPACITY,
|
||||||
&"generation": ManaReserveCycle.WORLD_TREE_GENERATION,
|
&"generation": generation,
|
||||||
&"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES,
|
&"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES,
|
||||||
&"demand": demand,
|
&"demand": demand,
|
||||||
&"net": ManaReserveCycle.WORLD_TREE_GENERATION - demand,
|
&"net": generation - demand,
|
||||||
&"last_flow": mana_reserve.get_last_flow(),
|
&"last_flow": mana_reserve.get_last_flow(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11032,8 +11058,10 @@ func _advance_mana(absolute_minute: int) -> void:
|
|||||||
return
|
return
|
||||||
# A06: generation and upkeep demand settle before local distribution and the
|
# A06: generation and upkeep demand settle before local distribution and the
|
||||||
# whole-tower gate, so the network and projection see the committed reserve.
|
# whole-tower gate, so the network and projection see the committed reserve.
|
||||||
|
# A13: mana fog raises the world tree yield of this boundary to x1.2 (floor).
|
||||||
var reserve_flow := mana_reserve.advance_to(
|
var reserve_flow := mana_reserve.advance_to(
|
||||||
absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand()
|
absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand(),
|
||||||
|
NaturalWeather.mana_yield(ManaReserveCycle.WORLD_TREE_GENERATION, weather.get_kind())
|
||||||
)
|
)
|
||||||
if not bool(reserve_flow.get(&"ok", false)):
|
if not bool(reserve_flow.get(&"ok", false)):
|
||||||
_latch_infrastructure_failure(
|
_latch_infrastructure_failure(
|
||||||
@@ -12392,6 +12420,8 @@ func _on_guest_despawned(guest_id: StringName, persistent_record: Dictionary) ->
|
|||||||
elevators.cancel_actor(actor_id)
|
elevators.cancel_actor(actor_id)
|
||||||
var path_removed := path_service.despawn_actor(actor_id)
|
var path_removed := path_service.despawn_actor(actor_id)
|
||||||
_movement_credits.erase(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()
|
_synchronize_operations_population()
|
||||||
var payload := persistent_record.duplicate(true)
|
var payload := persistent_record.duplicate(true)
|
||||||
payload[&"path_actor_id"] = actor_id
|
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:
|
func _advance_weather(absolute_minute: int) -> void:
|
||||||
weather.advance_to(absolute_minute)
|
weather.advance_to(absolute_minute)
|
||||||
|
_draw_natural_weather_if_due(absolute_minute)
|
||||||
|
if not weather.get_rain_holds().is_empty():
|
||||||
|
var present := {}
|
||||||
|
for guest: GuestRuntime in guest_service.get_guests():
|
||||||
|
present[guest.guest_id] = true
|
||||||
|
weather.prune_rain_holds(present)
|
||||||
|
|
||||||
|
|
||||||
|
## A13: one draw per day at 06:00 from the saved weather stream. The weather
|
||||||
|
## boundary runs first, so the same minute's arrivals and closes see the result.
|
||||||
|
func _draw_natural_weather_if_due(absolute_minute: int) -> void:
|
||||||
|
if not NaturalWeather.is_draw_minute(absolute_minute) or weather.get_minute() != absolute_minute:
|
||||||
|
return
|
||||||
|
var day := NaturalWeather.day_of(absolute_minute)
|
||||||
|
var stream := get_rng_stream(&"weather")
|
||||||
|
if day <= weather.get_last_draw_day() or stream == null:
|
||||||
|
return
|
||||||
|
var draw := NaturalWeather.draw(stream, day)
|
||||||
|
var applied := weather.apply_draw(draw)
|
||||||
|
var no_entities: Array[StringName] = []
|
||||||
|
_publish_actor_event(&"WEATHER_FORECAST_DRAWN", &"weather", no_entities, {
|
||||||
|
&"day": day,
|
||||||
|
&"kind": String(draw[&"kind"]),
|
||||||
|
&"roll": int(draw[&"roll"]),
|
||||||
|
&"slot": int(draw[&"slot"]),
|
||||||
|
&"start": int(draw[&"start"]),
|
||||||
|
&"end": int(draw[&"end"]),
|
||||||
|
&"applied": bool(applied.get(&"ok", false)),
|
||||||
|
&"code": String(applied.get(&"code", &"")),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
## A13: a guest due to leave while it rains stays until the rain ends, at most
|
||||||
|
## 240 minutes after the original plan, once per guest (SPEC section 4.3).
|
||||||
|
func _hold_guest_for_rain(guest: GuestRuntime, absolute_minute: int) -> bool:
|
||||||
|
if weather.get_kind() != &"rain" or not weather.get_rain_hold(guest.guest_id).is_empty():
|
||||||
|
return false
|
||||||
|
var original := guest.planned_exit_at
|
||||||
|
var extended := original + NaturalWeather.RAIN_HOLD_MAX_MINUTES
|
||||||
|
var rain_end := weather.rain_end_minute()
|
||||||
|
if rain_end >= 0:
|
||||||
|
extended = mini(extended, rain_end)
|
||||||
|
if extended <= absolute_minute or not weather.add_rain_hold(guest.guest_id, original, extended):
|
||||||
|
return false
|
||||||
|
guest.planned_exit_at = extended
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
func get_weather_report() -> Dictionary:
|
func get_weather_report() -> Dictionary:
|
||||||
return {&"kind": weather.get_kind(), &"minute": weather.get_minute()}
|
var kind := weather.get_kind()
|
||||||
|
return {
|
||||||
|
&"kind": kind,
|
||||||
|
&"minute": weather.get_minute(),
|
||||||
|
&"since": weather.get_since(),
|
||||||
|
&"next_change": weather.next_change(),
|
||||||
|
&"today_draw": weather.get_today_draw(),
|
||||||
|
&"effect_text": NaturalWeather.effect_text(kind),
|
||||||
|
&"arrival_percent": NaturalWeather.arrival_percent(kind),
|
||||||
|
&"rain_holds": weather.get_rain_holds().size(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
## Read-only migration preview; applying dimensions remains a separate transaction.
|
## Read-only migration preview; applying dimensions remains a separate transaction.
|
||||||
|
|||||||
@@ -3958,6 +3958,39 @@
|
|||||||
"path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md",
|
"path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md",
|
||||||
"scope": "new P05-02 F6 restart reader marker; native execution pending"
|
"scope": "new P05-02 F6 restart reader marker; native execution pending"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test": "tests/environment/test_p07_weather_runtime.gd",
|
||||||
|
"template": "PASS: <checks> checks",
|
||||||
|
"required": true,
|
||||||
|
"fixed_check_count_used": false,
|
||||||
|
"source_sha256_at_schema_audit": "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",
|
"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。
|
||||||
+18
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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`)。
|
||||||
@@ -136,6 +136,7 @@ var rank_button: Button
|
|||||||
var rank_dialog: ConfirmationDialog
|
var rank_dialog: ConfirmationDialog
|
||||||
var event_log_dialog: AcceptDialog
|
var event_log_dialog: AcceptDialog
|
||||||
var weather_label: Label
|
var weather_label: Label
|
||||||
|
var weather_forecast_label: Label
|
||||||
var guest_count_label: Button
|
var guest_count_label: Button
|
||||||
var dimension_migration_dialog: ConfirmationDialog
|
var dimension_migration_dialog: ConfirmationDialog
|
||||||
var _dimension_migration_digest := ""
|
var _dimension_migration_digest := ""
|
||||||
@@ -467,6 +468,11 @@ func _create_top_bar() -> Control:
|
|||||||
weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。"
|
weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。"
|
||||||
weather_label.mouse_filter = Control.MOUSE_FILTER_STOP
|
weather_label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
row.add_child(weather_label)
|
row.add_child(weather_label)
|
||||||
|
# A13 forecast: the next change of today's weather (kind and time).
|
||||||
|
weather_forecast_label = _hud_value_label("", 72)
|
||||||
|
weather_forecast_label.name = "WeatherForecastLabel"
|
||||||
|
weather_forecast_label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
row.add_child(weather_forecast_label)
|
||||||
|
|
||||||
guest_count_label = Button.new()
|
guest_count_label = Button.new()
|
||||||
guest_count_label.text = "来客 0人"
|
guest_count_label.text = "来客 0人"
|
||||||
@@ -1550,6 +1556,16 @@ func _refresh_dashboard() -> void:
|
|||||||
var weather_report: Dictionary = _runtime.get_weather_report()
|
var weather_report: Dictionary = _runtime.get_weather_report()
|
||||||
var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"}
|
var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"}
|
||||||
weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —")
|
weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —")
|
||||||
|
var next_change: Dictionary = weather_report.get(&"next_change", {})
|
||||||
|
var forecast := "次: 予定なし"
|
||||||
|
if not next_change.is_empty():
|
||||||
|
var change_minute := posmod(int(next_change.get(&"minute", 0)), 1_440)
|
||||||
|
forecast = "次: %s %02d:%02d" % [weather_names.get(StringName(next_change.get(&"kind", &"")), "天候 —"), change_minute / 60, change_minute % 60]
|
||||||
|
var effect := String(weather_report.get(&"effect_text", ""))
|
||||||
|
weather_label.tooltip_text = "%s\n毎朝6時にその日の天候が決まります。" % effect if not effect.is_empty() else weather_label.tooltip_text
|
||||||
|
if weather_forecast_label != null:
|
||||||
|
weather_forecast_label.text = forecast
|
||||||
|
weather_forecast_label.tooltip_text = "%s\n%s" % [forecast, effect]
|
||||||
var guests := _call_array(_service(&"guest_service"), &"get_guests")
|
var guests := _call_array(_service(&"guest_service"), &"get_guests")
|
||||||
var queued_guests := 0
|
var queued_guests := 0
|
||||||
var served_guests := 0
|
var served_guests := 0
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -2,14 +2,25 @@ extends RefCounted
|
|||||||
|
|
||||||
## Weather state and scheduled transitions. The caller owns generation policy;
|
## Weather state and scheduled transitions. The caller owns generation policy;
|
||||||
## this model invents neither probabilities nor rain/fog balance multipliers.
|
## this model invents neither probabilities nor rain/fog balance multipliers.
|
||||||
const VERSION := 1
|
## Version 2 also keeps the natural-weather bookkeeping (last daily draw, the
|
||||||
|
## current day's draw) and the once-per-guest rain holds; version 1 snapshots
|
||||||
|
## migrate with no draw for the past and no holds (P07-01 SPEC section 5).
|
||||||
|
const VERSION := 2
|
||||||
|
const LEGACY_VERSION := 1
|
||||||
const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"]
|
const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"]
|
||||||
const MAX_MINUTE := 2147483647
|
const MAX_MINUTE := 2147483647
|
||||||
const MAX_PENDING := 4096
|
const MAX_PENDING := 4096
|
||||||
|
const MAX_RAIN_HOLDS := 100000
|
||||||
|
const DAY_MINUTES := 1440
|
||||||
|
const DRAW_MINUTE_OF_DAY := 360
|
||||||
|
const RAIN_HOLD_MAX_MINUTES := 240
|
||||||
var _minute := -1
|
var _minute := -1
|
||||||
var _kind: StringName = &""
|
var _kind: StringName = &""
|
||||||
var _since := -1
|
var _since := -1
|
||||||
var _pending: Array[Dictionary] = []
|
var _pending: Array[Dictionary] = []
|
||||||
|
var _last_draw_day := -1
|
||||||
|
var _today_draw: Dictionary = {}
|
||||||
|
var _rain_holds: Dictionary = {}
|
||||||
|
|
||||||
func initialize(kind: Variant, minute: Variant) -> bool:
|
func initialize(kind: Variant, minute: Variant) -> bool:
|
||||||
if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute):
|
if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute):
|
||||||
@@ -17,6 +28,7 @@ func initialize(kind: Variant, minute: Variant) -> bool:
|
|||||||
_minute = minute
|
_minute = minute
|
||||||
_since = minute
|
_since = minute
|
||||||
_kind = StringName(kind)
|
_kind = StringName(kind)
|
||||||
|
_last_draw_day = _days_before(minute)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func get_kind() -> StringName:
|
func get_kind() -> StringName:
|
||||||
@@ -25,6 +37,9 @@ func get_kind() -> StringName:
|
|||||||
func get_minute() -> int:
|
func get_minute() -> int:
|
||||||
return _minute
|
return _minute
|
||||||
|
|
||||||
|
func get_since() -> int:
|
||||||
|
return _since
|
||||||
|
|
||||||
func permits_new_arrivals() -> bool:
|
func permits_new_arrivals() -> bool:
|
||||||
return _minute >= 0 and _kind != &"storm"
|
return _minute >= 0 and _kind != &"storm"
|
||||||
|
|
||||||
@@ -55,13 +70,100 @@ func advance_to(minute: Variant) -> Dictionary:
|
|||||||
_minute = minute
|
_minute = minute
|
||||||
return {&"ok": true, &"replayed": replayed, &"changes": changes}
|
return {&"ok": true, &"replayed": replayed, &"changes": changes}
|
||||||
|
|
||||||
|
## Natural weather (A13): the caller draws once per day at 06:00 and applies
|
||||||
|
## the result here. A start at the current minute switches now; later starts
|
||||||
|
## and the return to clear use the ordinary schedule rules, applied together.
|
||||||
|
func get_last_draw_day() -> int:
|
||||||
|
return _last_draw_day
|
||||||
|
|
||||||
|
func get_today_draw() -> Dictionary:
|
||||||
|
return _today_draw.duplicate(true)
|
||||||
|
|
||||||
|
func apply_draw(draw: Dictionary) -> Dictionary:
|
||||||
|
if _minute < 0 or not _valid_draw(draw):
|
||||||
|
return {&"ok": false, &"code": &"INVALID_DRAW"}
|
||||||
|
var day: int = draw[&"day"]
|
||||||
|
if day <= _last_draw_day or _minute != day * DAY_MINUTES + DRAW_MINUTE_OF_DAY:
|
||||||
|
return {&"ok": false, &"code": &"DRAW_NOT_DUE"}
|
||||||
|
var kind := StringName(draw[&"kind"])
|
||||||
|
var start: int = draw[&"start"]
|
||||||
|
var end: int = draw[&"end"]
|
||||||
|
var changes: Array[Dictionary] = []
|
||||||
|
if kind != &"clear":
|
||||||
|
for row: Dictionary in _pending:
|
||||||
|
if int(row[&"minute"]) == start or int(row[&"minute"]) == end:
|
||||||
|
return {&"ok": false, &"code": &"SCHEDULE_CONFLICT"}
|
||||||
|
if _pending.size() + 2 > MAX_PENDING:
|
||||||
|
return {&"ok": false, &"code": &"SCHEDULE_FULL"}
|
||||||
|
if start == _minute:
|
||||||
|
if kind != _kind:
|
||||||
|
changes.append({&"from": _kind, &"to": kind, &"minute": start})
|
||||||
|
_kind = kind
|
||||||
|
_since = start
|
||||||
|
else:
|
||||||
|
_pending.append({&"kind": kind, &"minute": start})
|
||||||
|
_pending.append({&"kind": &"clear", &"minute": end})
|
||||||
|
_pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"])
|
||||||
|
_last_draw_day = day
|
||||||
|
_today_draw = _copy_draw(draw)
|
||||||
|
return {&"ok": true, &"changes": changes}
|
||||||
|
|
||||||
|
## The next scheduled change away from the current kind, for the forecast.
|
||||||
|
func next_change() -> Dictionary:
|
||||||
|
for row: Dictionary in _pending:
|
||||||
|
if row[&"kind"] != _kind:
|
||||||
|
return {&"kind": row[&"kind"], &"minute": row[&"minute"]}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
## When the current rain ends (-1 when it is not raining or no end is known).
|
||||||
|
func rain_end_minute() -> int:
|
||||||
|
if _kind != &"rain":
|
||||||
|
return -1
|
||||||
|
for row: Dictionary in _pending:
|
||||||
|
if row[&"kind"] != &"rain":
|
||||||
|
return int(row[&"minute"])
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func get_rain_hold(guest_id: StringName) -> Dictionary:
|
||||||
|
var row: Variant = _rain_holds.get(String(guest_id), null)
|
||||||
|
return row.duplicate(true) if row is Dictionary else {}
|
||||||
|
|
||||||
|
func get_rain_holds() -> Dictionary:
|
||||||
|
return _rain_holds.duplicate(true)
|
||||||
|
|
||||||
|
func add_rain_hold(guest_id: StringName, original_exit: int, extended_to: int) -> bool:
|
||||||
|
var key := String(guest_id)
|
||||||
|
if key.is_empty() or _rain_holds.has(key) or _rain_holds.size() >= MAX_RAIN_HOLDS \
|
||||||
|
or not _valid_hold({"original_exit": original_exit, "extended_to": extended_to}):
|
||||||
|
return false
|
||||||
|
_rain_holds[key] = {"original_exit": original_exit, "extended_to": extended_to}
|
||||||
|
return true
|
||||||
|
|
||||||
|
## Holds of guests that have left are history; the caller passes who remains.
|
||||||
|
func prune_rain_holds(present_guest_ids: Dictionary) -> void:
|
||||||
|
for key: String in _rain_holds.keys():
|
||||||
|
if not present_guest_ids.has(StringName(key)):
|
||||||
|
_rain_holds.erase(key)
|
||||||
|
|
||||||
|
## 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:
|
func create_snapshot() -> Dictionary:
|
||||||
return {&"version": VERSION, &"minute": _minute, &"kind": _kind,
|
return {&"version": VERSION, &"minute": _minute, &"kind": _kind,
|
||||||
&"since": _since, &"pending": _pending.duplicate(true)}
|
&"since": _since, &"pending": _pending.duplicate(true),
|
||||||
|
&"natural": {&"last_draw_day": _last_draw_day, &"today": _today_draw.duplicate(true)},
|
||||||
|
&"rain_holds": _rain_holds.duplicate(true)}
|
||||||
|
|
||||||
func restore_snapshot(source: Dictionary) -> bool:
|
func restore_snapshot(source: Dictionary) -> bool:
|
||||||
if not _keys(source, ["version", "minute", "kind", "since", "pending"]) \
|
var version: Variant = source.get("version", source.get(&"version", null))
|
||||||
or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \
|
if typeof(version) != TYPE_INT or version not in [LEGACY_VERSION, VERSION]:
|
||||||
|
return false
|
||||||
|
var expected := ["version", "minute", "kind", "since", "pending"]
|
||||||
|
if version == VERSION:
|
||||||
|
expected = expected + ["natural", "rain_holds"]
|
||||||
|
if not _keys(source, expected) \
|
||||||
or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \
|
or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \
|
||||||
or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \
|
or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \
|
||||||
or not source["pending"] is Array or source["pending"].size() > MAX_PENDING:
|
or not source["pending"] is Array or source["pending"].size() > MAX_PENDING:
|
||||||
@@ -75,12 +177,75 @@ func restore_snapshot(source: Dictionary) -> bool:
|
|||||||
return false
|
return false
|
||||||
previous = value["minute"]
|
previous = value["minute"]
|
||||||
pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]})
|
pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]})
|
||||||
|
var last_draw_day := _days_before(source["minute"])
|
||||||
|
var today: Dictionary = {}
|
||||||
|
var holds: Dictionary = {}
|
||||||
|
if version == VERSION:
|
||||||
|
var natural: Variant = source["natural"]
|
||||||
|
if not natural is Dictionary or not _keys(natural, ["last_draw_day", "today"]) \
|
||||||
|
or typeof(natural["last_draw_day"]) != TYPE_INT or natural["last_draw_day"] < -1 \
|
||||||
|
or natural["last_draw_day"] > _day_of(source["minute"]) or not natural["today"] is Dictionary:
|
||||||
|
return false
|
||||||
|
last_draw_day = natural["last_draw_day"]
|
||||||
|
if not natural["today"].is_empty():
|
||||||
|
if not _valid_draw(natural["today"]) or int(natural["today"]["day"]) != last_draw_day:
|
||||||
|
return false
|
||||||
|
today = _copy_draw(natural["today"])
|
||||||
|
var raw_holds: Variant = source["rain_holds"]
|
||||||
|
if not raw_holds is Dictionary or raw_holds.size() > MAX_RAIN_HOLDS:
|
||||||
|
return false
|
||||||
|
for key: Variant in raw_holds:
|
||||||
|
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key).is_empty() \
|
||||||
|
or not raw_holds[key] is Dictionary or not _keys(raw_holds[key], ["original_exit", "extended_to"]) \
|
||||||
|
or not _valid_hold(raw_holds[key]):
|
||||||
|
return false
|
||||||
|
holds[String(key)] = {"original_exit": int(raw_holds[key]["original_exit"]),
|
||||||
|
"extended_to": int(raw_holds[key]["extended_to"])}
|
||||||
_minute = source["minute"]
|
_minute = source["minute"]
|
||||||
_kind = StringName(source["kind"])
|
_kind = StringName(source["kind"])
|
||||||
_since = source["since"]
|
_since = source["since"]
|
||||||
_pending = pending
|
_pending = pending
|
||||||
|
_last_draw_day = last_draw_day
|
||||||
|
_today_draw = today
|
||||||
|
_rain_holds = holds
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
## A day whose 06:00 is before the minute counts as drawn, so a new or migrated
|
||||||
|
## timeline never draws for the past (SPEC section 5.2). At exactly 06:00 the
|
||||||
|
## day is still due, which lets a new game draw its opening day.
|
||||||
|
static func _days_before(minute: int) -> int:
|
||||||
|
var day := _day_of(minute)
|
||||||
|
return day if posmod(minute, DAY_MINUTES) > DRAW_MINUTE_OF_DAY else day - 1
|
||||||
|
|
||||||
|
static func _day_of(minute: int) -> int:
|
||||||
|
return int(floor(float(minute) / DAY_MINUTES))
|
||||||
|
|
||||||
|
static func _valid_draw(draw: Variant) -> bool:
|
||||||
|
if not draw is Dictionary or not _keys(draw, ["day", "kind", "roll", "slot", "start", "end"]):
|
||||||
|
return false
|
||||||
|
for key: String in ["day", "roll", "slot", "start", "end"]:
|
||||||
|
if typeof(draw[key]) != TYPE_INT:
|
||||||
|
return false
|
||||||
|
if not _valid_kind(draw["kind"]) or draw["day"] < 0 or draw["roll"] < 0 or draw["roll"] > 99:
|
||||||
|
return false
|
||||||
|
if StringName(draw["kind"]) == &"clear":
|
||||||
|
return draw["slot"] == -1 and draw["start"] == -1 and draw["end"] == -1
|
||||||
|
var day_start: int = draw["day"] * DAY_MINUTES + DRAW_MINUTE_OF_DAY
|
||||||
|
return draw["slot"] >= 0 and draw["start"] >= day_start and draw["start"] <= day_start + 14 * 60 \
|
||||||
|
and draw["end"] > draw["start"] and draw["end"] < day_start + DAY_MINUTES
|
||||||
|
|
||||||
|
static func _copy_draw(draw: Dictionary) -> Dictionary:
|
||||||
|
var result := {}
|
||||||
|
for key: String in ["day", "kind", "roll", "slot", "start", "end"]:
|
||||||
|
result[key] = draw[key] if key != "kind" else String(draw[key])
|
||||||
|
return result
|
||||||
|
|
||||||
|
static func _valid_hold(row: Dictionary) -> bool:
|
||||||
|
var original: Variant = row.get("original_exit", null)
|
||||||
|
var extended: Variant = row.get("extended_to", null)
|
||||||
|
return typeof(original) == TYPE_INT and typeof(extended) == TYPE_INT and original >= 0 \
|
||||||
|
and extended > original and extended <= original + RAIN_HOLD_MAX_MINUTES
|
||||||
|
|
||||||
static func _valid_kind(value: Variant) -> bool:
|
static func _valid_kind(value: Variant) -> bool:
|
||||||
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS
|
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS
|
||||||
|
|
||||||
|
|||||||
@@ -365,6 +365,10 @@ func _update_needs_runtime(
|
|||||||
delta_medical += int(normalized_effects.get(&"medical", 0))
|
delta_medical += int(normalized_effects.get(&"medical", 0))
|
||||||
delta_leisure += int(normalized_effects.get(&"leisure", 0))
|
delta_leisure += int(normalized_effects.get(&"leisure", 0))
|
||||||
delta_intimacy += int(normalized_effects.get(&"intimacy", 0))
|
delta_intimacy += int(normalized_effects.get(&"intimacy", 0))
|
||||||
|
## A13: mana fog raises an adult-verified guest's positive intimacy increase
|
||||||
|
## to floor(x1.5) = n + floor(n / 2); decreases and other guests are unchanged.
|
||||||
|
if definition.adult_verified and delta_intimacy > 0 and bool(_value(context, &"mana_fog", false)):
|
||||||
|
delta_intimacy += delta_intimacy >> 1
|
||||||
|
|
||||||
var after_hunger := clampi(before_hunger + delta_hunger, 0, 100)
|
var after_hunger := clampi(before_hunger + delta_hunger, 0, 100)
|
||||||
var after_sleep := clampi(before_sleep + delta_sleep, 0, 100)
|
var after_sleep := clampi(before_sleep + delta_sleep, 0, 100)
|
||||||
@@ -1157,7 +1161,7 @@ func _need_context_error(context: Dictionary) -> String:
|
|||||||
var raw_value: Variant = _value(context, field_id, null)
|
var raw_value: Variant = _value(context, field_id, null)
|
||||||
if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100:
|
if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100:
|
||||||
return "%s must be an integer from 0 through 100." % field_id
|
return "%s must be an integer from 0 through 100." % field_id
|
||||||
for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated"]:
|
for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated", &"mana_fog"]:
|
||||||
if _has_value(context, field_id) and not _value(context, field_id, null) is bool:
|
if _has_value(context, field_id) and not _value(context, field_id, null) is bool:
|
||||||
return "%s must be boolean." % field_id
|
return "%s must be boolean." % field_id
|
||||||
if _has_value(context, &"need_effects"):
|
if _has_value(context, &"need_effects"):
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const CAPACITY := 300
|
|||||||
const WORLD_TREE_GENERATION := 20
|
const WORLD_TREE_GENERATION := 20
|
||||||
const INTERVAL_MINUTES := 10
|
const INTERVAL_MINUTES := 10
|
||||||
const FURNACE_YIELD := 100
|
const FURNACE_YIELD := 100
|
||||||
|
## A13 mana fog: world tree and furnace yields x1.2 (floor) at fog boundaries.
|
||||||
|
const FOG_WORLD_TREE_GENERATION := 24
|
||||||
|
const FOG_FURNACE_YIELD := 120
|
||||||
|
const GENERATIONS: Array[int] = [WORLD_TREE_GENERATION, FOG_WORLD_TREE_GENERATION]
|
||||||
const LIMIT := 1000000000
|
const LIMIT := 1000000000
|
||||||
const FLOW_KEYS: Array[String] = [
|
const FLOW_KEYS: Array[String] = [
|
||||||
"from_minute", "to_minute", "before", "generation", "furnace", "demand",
|
"from_minute", "to_minute", "before", "generation", "furnace", "demand",
|
||||||
@@ -35,12 +39,14 @@ func get_last_flow() -> Dictionary:
|
|||||||
## Processes every boundary in (last boundary, minute]. Boundaries already
|
## Processes every boundary in (last boundary, minute]. Boundaries already
|
||||||
## processed are no-ops, so per-minute callers and same-minute retries are safe.
|
## processed are no-ops, so per-minute callers and same-minute retries are safe.
|
||||||
## Mana may rise between boundaries (event refunds, commands); anything above
|
## Mana may rise between boundaries (event refunds, commands); anything above
|
||||||
## capacity is discarded and recorded as overflow at the next boundary.
|
## capacity is discarded and recorded as overflow at the next boundary. The
|
||||||
func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary:
|
## caller passes the world tree yield of the boundary (A13 fog: 24, else 20).
|
||||||
|
func advance_to(minute: Variant, before: Variant, demand: Variant, generation: Variant = WORLD_TREE_GENERATION) -> Dictionary:
|
||||||
if _minute < 0:
|
if _minute < 0:
|
||||||
return _failure(&"NOT_STARTED")
|
return _failure(&"NOT_STARTED")
|
||||||
if typeof(minute) != TYPE_INT or not _amount(before) \
|
if typeof(minute) != TYPE_INT or not _amount(before) \
|
||||||
or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute:
|
or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute \
|
||||||
|
or typeof(generation) != TYPE_INT or generation not in GENERATIONS:
|
||||||
return _failure(&"INVALID_FLOW")
|
return _failure(&"INVALID_FLOW")
|
||||||
var boundary: int = minute - minute % INTERVAL_MINUTES
|
var boundary: int = minute - minute % INTERVAL_MINUTES
|
||||||
if boundary <= _minute:
|
if boundary <= _minute:
|
||||||
@@ -49,7 +55,7 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary
|
|||||||
var flow := {}
|
var flow := {}
|
||||||
var count := 0
|
var count := 0
|
||||||
for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES):
|
for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES):
|
||||||
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute)
|
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute, generation)
|
||||||
amount = flow[&"after"]
|
amount = flow[&"after"]
|
||||||
count += 1
|
count += 1
|
||||||
_minute = boundary
|
_minute = boundary
|
||||||
@@ -57,17 +63,18 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary
|
|||||||
return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)}
|
return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)}
|
||||||
|
|
||||||
## Furnace offers are judged after natural generation and upkeep demand are
|
## Furnace offers are judged after natural generation and upkeep demand are
|
||||||
## paid: base_after = min(C, max(0, B + G - D)); accept only with 100 free.
|
## paid: base_after = min(C, max(0, B + G - D)); accept only with the yield free
|
||||||
static func furnace_offer_accepted(before: int, demand: int) -> bool:
|
## (100, or 120 at an A13 fog boundary where G is 24).
|
||||||
var base_after := mini(CAPACITY, maxi(0, before + WORLD_TREE_GENERATION - demand))
|
static func furnace_offer_accepted(before: int, demand: int, generation: int = WORLD_TREE_GENERATION, furnace_yield: int = FURNACE_YIELD) -> bool:
|
||||||
return CAPACITY - base_after >= FURNACE_YIELD
|
var base_after := mini(CAPACITY, maxi(0, before + generation - demand))
|
||||||
|
return CAPACITY - base_after >= furnace_yield
|
||||||
|
|
||||||
static func plan(before: int, demand: int, furnace: int, start: int, end: int) -> Dictionary:
|
static func plan(before: int, demand: int, furnace: int, start: int, end: int, generation: int = WORLD_TREE_GENERATION) -> Dictionary:
|
||||||
var available := before + WORLD_TREE_GENERATION + furnace
|
var available := before + generation + furnace
|
||||||
var consumed := mini(available, demand)
|
var consumed := mini(available, demand)
|
||||||
var remaining := available - consumed
|
var remaining := available - consumed
|
||||||
return {&"from_minute": start, &"to_minute": end, &"before": before,
|
return {&"from_minute": start, &"to_minute": end, &"before": before,
|
||||||
&"generation": WORLD_TREE_GENERATION, &"furnace": furnace, &"demand": demand,
|
&"generation": generation, &"furnace": furnace, &"demand": demand,
|
||||||
&"consumed": consumed, &"shortage": demand - consumed,
|
&"consumed": consumed, &"shortage": demand - consumed,
|
||||||
&"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)}
|
&"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)}
|
||||||
|
|
||||||
@@ -105,10 +112,10 @@ func restore_snapshot(source: Variant) -> bool:
|
|||||||
if not _amount(flow[key]):
|
if not _amount(flow[key]):
|
||||||
return false
|
return false
|
||||||
if flow["to_minute"] != source["minute"] \
|
if flow["to_minute"] != source["minute"] \
|
||||||
or flow["generation"] != WORLD_TREE_GENERATION \
|
or flow["generation"] not in GENERATIONS \
|
||||||
or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]:
|
or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]:
|
||||||
return false
|
return false
|
||||||
var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"])
|
var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"], flow["generation"])
|
||||||
if not _same_flow(expected, flow):
|
if not _same_flow(expected, flow):
|
||||||
return false
|
return false
|
||||||
_minute = source["minute"]
|
_minute = source["minute"]
|
||||||
|
|||||||
@@ -1013,6 +1013,15 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
|
|||||||
&"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID"
|
&"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID"
|
||||||
)
|
)
|
||||||
var external_capacity := int(capacity_value)
|
var external_capacity := int(capacity_value)
|
||||||
|
# A13: the arrival boundary reports the weather share of planned arrivals
|
||||||
|
# (rain 50, otherwise 100). Absent means 100 for callers without weather.
|
||||||
|
var percent_value: Variant = capture.get(&"arrival_percent", 100)
|
||||||
|
if not _is_integral_value(percent_value) or int(percent_value) < 0 or int(percent_value) > 100:
|
||||||
|
return _failed_boundary_after_capture(
|
||||||
|
_arrival_boundary, capture_payload, token,
|
||||||
|
&"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID"
|
||||||
|
)
|
||||||
|
var arrival_percent := int(percent_value)
|
||||||
var remaining := mini(
|
var remaining := mini(
|
||||||
mini(local_capacity, external_capacity),
|
mini(local_capacity, external_capacity),
|
||||||
int(_config[&"max_arrivals_per_interval"])
|
int(_config[&"max_arrivals_per_interval"])
|
||||||
@@ -1022,6 +1031,8 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
|
|||||||
var plans: Array[Dictionary] = []
|
var plans: Array[Dictionary] = []
|
||||||
var total_count := 0
|
var total_count := 0
|
||||||
var capped_rows: Array[Dictionary] = []
|
var capped_rows: Array[Dictionary] = []
|
||||||
|
var accepted_by_row: Array[int] = []
|
||||||
|
var planned_total := 0
|
||||||
for row: Dictionary in intent_rows:
|
for row: Dictionary in intent_rows:
|
||||||
var accepted := mini(int(row[&"desired_count"]), remaining)
|
var accepted := mini(int(row[&"desired_count"]), remaining)
|
||||||
var key: String = row[&"key"]
|
var key: String = row[&"key"]
|
||||||
@@ -1034,6 +1045,21 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
|
|||||||
&"guest_class": String(row[&"guest_class"]),
|
&"guest_class": String(row[&"guest_class"]),
|
||||||
&"discarded_numerator": retained - capped_retained,
|
&"discarded_numerator": retained - capped_retained,
|
||||||
})
|
})
|
||||||
|
accepted_by_row.append(accepted)
|
||||||
|
planned_total += accepted
|
||||||
|
remaining -= accepted
|
||||||
|
# Rain keeps floor(planned * percent / 100) arrivals; the dropped arrivals
|
||||||
|
# are lost to the weather and are not carried to the next boundary.
|
||||||
|
var dropped := planned_total - planned_total * arrival_percent / 100
|
||||||
|
var drop_index := accepted_by_row.size() - 1
|
||||||
|
while dropped > 0 and drop_index >= 0:
|
||||||
|
var cut := mini(dropped, accepted_by_row[drop_index])
|
||||||
|
accepted_by_row[drop_index] -= cut
|
||||||
|
dropped -= cut
|
||||||
|
drop_index -= 1
|
||||||
|
for row_index in intent_rows.size():
|
||||||
|
var row: Dictionary = intent_rows[row_index]
|
||||||
|
var accepted := accepted_by_row[row_index]
|
||||||
if accepted <= 0:
|
if accepted <= 0:
|
||||||
continue
|
continue
|
||||||
if plan_sequence > MAX_SEQUENCE_VALUE \
|
if plan_sequence > MAX_SEQUENCE_VALUE \
|
||||||
@@ -1058,7 +1084,6 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
|
|||||||
})
|
})
|
||||||
plan_sequence += 1
|
plan_sequence += 1
|
||||||
total_count += accepted
|
total_count += accepted
|
||||||
remaining -= accepted
|
|
||||||
|
|
||||||
var payload := capture_payload.duplicate(true)
|
var payload := capture_payload.duplicate(true)
|
||||||
payload[&"plans"] = plans
|
payload[&"plans"] = plans
|
||||||
|
|||||||
@@ -276,8 +276,9 @@ func _legacy_controls(writer: Dictionary, reader: Dictionary) -> void:
|
|||||||
|
|
||||||
## Approved later save migrations applied when an older save is loaded, each
|
## Approved later save migrations applied when an older save is loaded, each
|
||||||
## checked against its own contract and then mapped into `expected` in place:
|
## 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
|
## 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.
|
## 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:
|
func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> Dictionary:
|
||||||
var old: Dictionary = expected[&"systems"][&"runtime"]
|
var old: Dictionary = expected[&"systems"][&"runtime"]
|
||||||
var now: Dictionary = reader[&"systems"][&"runtime"]
|
var now: Dictionary = reader[&"systems"][&"runtime"]
|
||||||
@@ -322,6 +323,35 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
|
|||||||
old[&"economy"] = economy
|
old[&"economy"] = economy
|
||||||
fields.append("economy_A12_empty_fund_accounts")
|
fields.append("economy_A12_empty_fund_accounts")
|
||||||
proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added}
|
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():
|
if not fields.is_empty():
|
||||||
# Saves keep the Runtime's own key order, so the mapped tree follows it.
|
# Saves keep the Runtime's own key order, so the mapped tree follows it.
|
||||||
ok = ok and old.size() == now.size()
|
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
|
||||||
@@ -20,10 +20,15 @@ func run() -> void:
|
|||||||
legacy[&"systems"][&"runtime"].erase(&"weather")
|
legacy[&"systems"][&"runtime"].erase(&"weather")
|
||||||
legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness")
|
legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness")
|
||||||
expect(runtime.restore_snapshot(legacy) and runtime.weather.get_kind() == &"clear" and runtime.weather.get_minute() == 360, "v10 migrates to neutral weather at saved minute")
|
expect(runtime.restore_snapshot(legacy) and runtime.weather.get_kind() == &"clear" and runtime.weather.get_minute() == 360, "v10 migrates to neutral weather at saved minute")
|
||||||
expect(runtime.create_snapshot() == original, "old neutral save migration preserves other state")
|
# P07-01 (A13): the original carries the opening day's natural draw, which a
|
||||||
|
# pre-weather save never had; every other system must stay byte-equal.
|
||||||
|
var migrated: Dictionary = runtime.create_snapshot()
|
||||||
|
var migrated_rest: Dictionary = migrated.duplicate(true); migrated_rest[&"systems"][&"runtime"].erase(&"weather")
|
||||||
|
var original_rest: Dictionary = original.duplicate(true); original_rest[&"systems"][&"runtime"].erase(&"weather")
|
||||||
|
expect(migrated_rest == original_rest and runtime.weather.get_last_draw_day() == -1 and runtime.weather.get_today_draw().is_empty(), "old neutral save migration preserves other state")
|
||||||
var malformed := original.duplicate(true)
|
var malformed := original.duplicate(true)
|
||||||
malformed[&"systems"][&"runtime"][&"weather"][&"minute"] = 361
|
malformed[&"systems"][&"runtime"][&"weather"][&"minute"] = 361
|
||||||
expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == original, "future weather clock rejects atomically")
|
expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == migrated, "future weather clock rejects atomically")
|
||||||
malformed = original.duplicate(true)
|
malformed = original.duplicate(true)
|
||||||
malformed[&"systems"][&"runtime"].erase(&"weather")
|
malformed[&"systems"][&"runtime"].erase(&"weather")
|
||||||
expect(not runtime.restore_snapshot(malformed), "new schema cannot silently lose weather")
|
expect(not runtime.restore_snapshot(malformed), "new schema cannot silently lose weather")
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ func run() -> void:
|
|||||||
var completed: Dictionary = runtime.create_snapshot()
|
var completed: Dictionary = runtime.create_snapshot()
|
||||||
expect(runtime.restore_snapshot(completed) and runtime.create_snapshot() == completed, "completed retry restores exactly")
|
expect(runtime.restore_snapshot(completed) and runtime.create_snapshot() == completed, "completed retry restores exactly")
|
||||||
clock.advance_minutes(30)
|
clock.advance_minutes(30)
|
||||||
expect(runtime.weather.get_kind() == &"clear" and runtime.weather.create_snapshot()[&"pending"].is_empty(), "disk-restored recovery occurs once at scheduled minute")
|
# P07-01 (A13): only the opening day's natural draw may remain scheduled.
|
||||||
|
var today: Dictionary = runtime.weather.get_today_draw()
|
||||||
|
var natural_pending: Array = [] if String(today.get("kind", "clear")) == "clear" else [
|
||||||
|
{&"kind": StringName(today["kind"]), &"minute": int(today["start"])}, {&"kind": &"clear", &"minute": int(today["end"])}]
|
||||||
|
expect(runtime.weather.get_kind() == &"clear" and runtime.weather.create_snapshot()[&"pending"] == natural_pending, "disk-restored recovery occurs once at scheduled minute")
|
||||||
expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "recovery resumes ordinary operations")
|
expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "recovery resumes ordinary operations")
|
||||||
expect(SaveService.delete_save(path), "delete isolated test save")
|
expect(SaveService.delete_save(path), "delete isolated test save")
|
||||||
runtime.free(); clock.free(); state.free()
|
runtime.free(); clock.free(); state.free()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func _initialize() -> void:
|
|||||||
for field: String in ["version", "minute", "kind", "since", "pending"]:
|
for field: String in ["version", "minute", "kind", "since", "pending"]:
|
||||||
var bad := baseline.duplicate(true); bad.erase(field)
|
var bad := baseline.duplicate(true); bad.erase(field)
|
||||||
expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "missing field rejects atomically: " + field)
|
expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "missing field rejects atomically: " + field)
|
||||||
for change: Dictionary in [{"version": 2}, {"minute": 380.0}, {"kind": "snow"}, {"since": 381}, {"pending": [{"kind":"clear","minute":380}]}, {"pending": [{"kind":"clear","minute":400},{"kind":"rain","minute":390}]}, {"extra":true}]:
|
for change: Dictionary in [{"version": 3}, {"minute": 380.0}, {"kind": "snow"}, {"since": 381}, {"pending": [{"kind":"clear","minute":380}]}, {"pending": [{"kind":"clear","minute":400},{"kind":"rain","minute":390}]}, {"extra":true}]:
|
||||||
var bad := baseline.duplicate(true)
|
var bad := baseline.duplicate(true)
|
||||||
bad.merge(change, true)
|
bad.merge(change, true)
|
||||||
expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "malformed or contradictory state rejects atomically")
|
expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "malformed or contradictory state rejects atomically")
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -438,6 +438,28 @@
|
|||||||
"old_bounded_teardown": false,
|
"old_bounded_teardown": false,
|
||||||
"new_audio_QA": false
|
"new_audio_QA": false
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
"tests/environment/test_p07_weather_runtime.gd": {
|
||||||
|
"source_sha256": "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": {
|
"outside_routes": {
|
||||||
"tests/supply/test_normal_auto_purchase_chain.gd": {
|
"tests/supply/test_normal_auto_purchase_chain.gd": {
|
||||||
@@ -474,6 +496,9 @@
|
|||||||
"tests/economy/test_p05_funds_restart_1_write.gd": [
|
"tests/economy/test_p05_funds_restart_1_write.gd": [
|
||||||
"artifacts/p05-02/restart-handoff.json"
|
"artifacts/p05-02/restart-handoff.json"
|
||||||
],
|
],
|
||||||
|
"tests/environment/test_p07_weather_restart_1_write.gd": [
|
||||||
|
"artifacts/p07-01/restart-handoff.json"
|
||||||
|
],
|
||||||
"tests/mvp/test_runtime_upkeep.gd": [
|
"tests/mvp/test_runtime_upkeep.gd": [
|
||||||
"artifacts/upkeep-2026-09-29/preclose-snapshot.txt",
|
"artifacts/upkeep-2026-09-29/preclose-snapshot.txt",
|
||||||
"artifacts/upkeep-2026-09-29/closed-snapshot.txt"
|
"artifacts/upkeep-2026-09-29/closed-snapshot.txt"
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user