486 lines
18 KiB
GDScript
486 lines
18 KiB
GDScript
extends SceneTree
|
|
|
|
const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY
|
|
const RUNTIME_SNAPSHOT_VERSION := 7
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("MVP: production Operations acceptance")
|
|
_test_waiting_choice_and_command_exactly_once()
|
|
_test_twenty_four_hour_actual_tick_smoke()
|
|
_test_full_partial_payroll_bankruptcy_and_v7_tamper()
|
|
_test_fourteen_day_boundary_snapshot_determinism()
|
|
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 _new_fixture(seed: int, enable_operations := true) -> Dictionary:
|
|
var clock: Variant = load("res://autoload/game_clock.gd").new()
|
|
var state: Variant = load("res://autoload/game_state.gd").new()
|
|
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
|
|
clock.reset(1, 6, 0)
|
|
state.reset()
|
|
var initialized: Dictionary = runtime.initialize(clock, state, seed)
|
|
clock.set_paused(true)
|
|
_expect(bool(initialized.get(&"valid", false)), "fixture Runtime initializes")
|
|
var initial_snapshot: Dictionary = runtime.create_snapshot()
|
|
_expect(
|
|
int(initial_snapshot[&"systems"][&"runtime"].get(
|
|
&"runtime_schema_version", -1
|
|
)) == RUNTIME_SNAPSHOT_VERSION,
|
|
"fixture emits Runtime snapshot schema v7"
|
|
)
|
|
if enable_operations:
|
|
var enabled: Dictionary = runtime.enable_production_operations()
|
|
_expect(bool(enabled.get(&"ok", false)), "fixture enables production Operations")
|
|
return {&"clock": clock, &"state": state, &"runtime": runtime}
|
|
|
|
|
|
func _free_fixture(fixture: Dictionary) -> void:
|
|
var runtime: Variant = fixture.get(&"runtime")
|
|
var clock: Variant = fixture.get(&"clock")
|
|
var state: Variant = fixture.get(&"state")
|
|
if runtime != null:
|
|
runtime.free()
|
|
if clock != null:
|
|
clock.free()
|
|
if state != null:
|
|
state.free()
|
|
|
|
|
|
# Long acceptance slices deliberately exercise the production Operations
|
|
# adapter without claiming to execute unrelated actor AI on all 20,160 minutes.
|
|
# Aggregate clocks are aligned solely to preserve the Runtime transaction
|
|
# precondition; the separate 24-hour smoke above covers the actual Tick pipeline.
|
|
# One final rich snapshot (and the midpoint restore) is the Operations oracle.
|
|
func _advance_operations_boundary_slice(fixture: Dictionary, amount: int) -> int:
|
|
var clock: Variant = fixture[&"clock"]
|
|
var runtime: Variant = fixture[&"runtime"]
|
|
var advanced := 0
|
|
while advanced < amount:
|
|
if runtime.operations_coordinator != null \
|
|
and runtime.operations_coordinator.is_terminal():
|
|
break
|
|
# Operations has no recurring boundary finer than 30 minutes. The
|
|
# coordinator still performs its own unthinned internal minute traversal;
|
|
# unrelated Runtime AI is intentionally outside this accelerated oracle.
|
|
var step := mini(30, amount - advanced)
|
|
var target: int = clock.absolute_minute + step
|
|
clock.absolute_minute = target
|
|
clock.call("_sync_calendar_fields")
|
|
clock.snapshot_version += step
|
|
runtime.call("_advance_operations_time", target)
|
|
if bool(runtime.get_operations_report().get(&"runtime_failure_latched", false)):
|
|
break
|
|
if runtime.operations_coordinator.is_terminal():
|
|
# A terminal daily close commits TIME_BOUNDARIES and deliberately skips
|
|
# the Runtime suffix; its other aggregates therefore remain at X - 1.
|
|
_align_operations_fixture_clocks(runtime, target - 1)
|
|
advanced += step
|
|
break
|
|
# These acceptance fixtures contain no Facilities, logistics, raids, or
|
|
# Staff tasks. Their clocks are aligned for Operations cross-link checks;
|
|
# their skipped actor/Shift behavior is explicitly outside this fast oracle.
|
|
_align_operations_fixture_clocks(runtime, target)
|
|
advanced += step
|
|
return advanced
|
|
|
|
|
|
func _align_operations_fixture_clocks(runtime: Variant, minute: int) -> void:
|
|
runtime.inventory_service.current_minute = minute
|
|
runtime.facility_service.current_minute = minute
|
|
runtime.kitchen_production.current_minute = minute
|
|
runtime.guest_service.current_minute = minute
|
|
runtime.staff_service.current_minute = minute
|
|
runtime.authority_service.current_minute = minute
|
|
runtime.defense_service.current_minute = minute
|
|
runtime.construction_service.current_minute = minute
|
|
runtime.excavation_service.current_minute = minute
|
|
|
|
|
|
func _operations_slice_is_healthy(fixture: Dictionary) -> bool:
|
|
var runtime: Variant = fixture[&"runtime"]
|
|
var report: Dictionary = runtime.get_operations_report()
|
|
var foreground: EventInstance = runtime.event_service.get_foreground_instance()
|
|
return not bool(report.get(&"runtime_failure_latched", true)) \
|
|
and int(report.get(&"deferred_runtime_minute", -2)) == -1 \
|
|
and not runtime.operations_coordinator.pause_requested \
|
|
and not runtime.operations_coordinator.is_terminal() \
|
|
and not runtime.operations_coordinator.is_external_quarantined() \
|
|
and (foreground == null or foreground.state != EventInstance.State.WAITING_CHOICE)
|
|
|
|
|
|
func _test_waiting_choice_and_command_exactly_once() -> void:
|
|
var fixture := _new_fixture(73_001)
|
|
var runtime: Variant = fixture[&"runtime"]
|
|
var clock: Variant = fixture[&"clock"]
|
|
var state: Variant = fixture[&"state"]
|
|
|
|
var queued: CommandResult = runtime.event_service.enqueue_candidate(
|
|
&"relationship.threshold",
|
|
[],
|
|
clock.absolute_minute,
|
|
{&"deduplication_key": &"operations-acceptance-choice"}
|
|
)
|
|
var started: CommandResult = runtime.event_service.start_next_event(
|
|
clock.absolute_minute, {}
|
|
)
|
|
_expect(
|
|
queued.is_success() and started.is_success()
|
|
and bool(started.details.get(&"waiting_for_choice", false)),
|
|
"foreground Event enters WAITING_CHOICE"
|
|
)
|
|
clock.set_paused(false)
|
|
var before_choice_minute: int = clock.absolute_minute
|
|
clock.advance_minutes(5)
|
|
var foreground: EventInstance = runtime.event_service.get_foreground_instance()
|
|
_expect(
|
|
clock.paused and clock.absolute_minute == before_choice_minute + 1,
|
|
"WAITING_CHOICE pauses at the first committed minute and stops catch-up"
|
|
)
|
|
_expect(
|
|
foreground != null and foreground.state == EventInstance.State.WAITING_CHOICE,
|
|
"paused foreground ownership remains intact"
|
|
)
|
|
if foreground != null:
|
|
var choice_result: CommandResult = runtime.submit_command(
|
|
&"submit_event_choice",
|
|
{
|
|
&"event_instance_id": foreground.event_instance_id,
|
|
&"choice_instance_id": foreground.choice_instance_id,
|
|
&"choice_id": &"acknowledge",
|
|
},
|
|
&"player",
|
|
foreground.event_instance_id,
|
|
100,
|
|
-1,
|
|
&"operations.acceptance.choice"
|
|
)
|
|
_expect(
|
|
choice_result.is_success()
|
|
and runtime.event_service.get_foreground_instance() == null
|
|
and not clock.paused,
|
|
"submitting the stable Choice token resolves the Event and resumes time"
|
|
)
|
|
|
|
clock.set_paused(true)
|
|
var gold_before: int = state.get_resource(&"gold")
|
|
var first: CommandResult = runtime.submit_command(
|
|
&"change_resources",
|
|
{&"deltas": {&"gold": 17}},
|
|
&"player",
|
|
&"",
|
|
50,
|
|
-1,
|
|
&"operations.acceptance.exactly-once"
|
|
)
|
|
var gold_after_first: int = state.get_resource(&"gold")
|
|
var retry: CommandResult = runtime.submit_command(
|
|
&"change_resources",
|
|
{&"deltas": {&"gold": 17}},
|
|
&"player",
|
|
&"",
|
|
50,
|
|
-1,
|
|
&"operations.acceptance.exactly-once"
|
|
)
|
|
_expect(
|
|
first.is_success() and gold_after_first == gold_before + 17,
|
|
"first public command commits its resource delta once"
|
|
)
|
|
_expect(
|
|
not retry.is_success()
|
|
and retry.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY
|
|
and state.get_resource(&"gold") == gold_after_first,
|
|
"transport retry with the same idempotency key cannot apply twice"
|
|
)
|
|
var command_snapshot: Dictionary = runtime.create_snapshot()
|
|
_expect(
|
|
runtime.restore_snapshot(command_snapshot),
|
|
"command history survives an exact v7 snapshot round trip"
|
|
)
|
|
var restored_retry: CommandResult = runtime.submit_command(
|
|
&"change_resources",
|
|
{&"deltas": {&"gold": 17}},
|
|
&"player",
|
|
&"",
|
|
50,
|
|
-1,
|
|
&"operations.acceptance.exactly-once"
|
|
)
|
|
_expect(
|
|
not restored_retry.is_success()
|
|
and restored_retry.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY
|
|
and state.get_resource(&"gold") == gold_after_first,
|
|
"restored idempotency history still rejects the same command"
|
|
)
|
|
_free_fixture(fixture)
|
|
|
|
|
|
func _test_twenty_four_hour_actual_tick_smoke() -> void:
|
|
var fixture := _new_fixture(73_004)
|
|
var runtime: Variant = fixture[&"runtime"]
|
|
var clock: Variant = fixture[&"clock"]
|
|
var start_minute: int = clock.absolute_minute
|
|
clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY)
|
|
_expect(
|
|
clock.absolute_minute - start_minute == SimulationKernel.MINUTES_PER_DAY,
|
|
"actual SimulationKernel ticks complete an uninterrupted 24-hour smoke slice"
|
|
)
|
|
_expect(
|
|
_operations_slice_is_healthy(fixture)
|
|
and runtime.operations_coordinator.current_minute == clock.absolute_minute
|
|
and runtime.operations_coordinator.get_day_summaries().size() == 1,
|
|
"24-hour actual-tick slice has no Choice, deferred minute, quarantine, or terminal stop"
|
|
)
|
|
var snapshot: Dictionary = runtime.create_snapshot()
|
|
_expect(
|
|
runtime.restore_snapshot(snapshot)
|
|
and runtime.latest_digest == SimulationDigest.compute(snapshot),
|
|
"24-hour actual-tick v7 snapshot round trip is exact"
|
|
)
|
|
_free_fixture(fixture)
|
|
|
|
|
|
func _test_full_partial_payroll_bankruptcy_and_v7_tamper() -> void:
|
|
var fixture := _new_fixture(73_002, false)
|
|
var runtime: Variant = fixture[&"runtime"]
|
|
var clock: Variant = fixture[&"clock"]
|
|
var state: Variant = fixture[&"state"]
|
|
var candidate: Dictionary = runtime.get_mvp_staff_candidate_payload(&"staff_akane")
|
|
var daily_wage := int(candidate[&"options"][&"daily_wage"])
|
|
var hired: CommandResult = runtime.submit_command(
|
|
&"hire_staff_candidate",
|
|
{&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO},
|
|
&"player",
|
|
&"staff_akane",
|
|
80,
|
|
-1,
|
|
&"operations.acceptance.hire"
|
|
)
|
|
_expect(hired.is_success() and daily_wage > 50, "payroll fixture hires one wage-bearing Staff member")
|
|
var enabled: Dictionary = runtime.enable_production_operations()
|
|
_expect(bool(enabled.get(&"ok", false)), "payroll fixture enables Operations after the pre-opening hire")
|
|
|
|
var full_advanced := _advance_operations_boundary_slice(
|
|
fixture, SimulationKernel.MINUTES_PER_DAY
|
|
)
|
|
var full: Dictionary = runtime.staff_service.get_payroll_settlement(&"payroll-day-00000001")
|
|
_expect(
|
|
full_advanced == SimulationKernel.MINUTES_PER_DAY
|
|
and _operations_slice_is_healthy(fixture)
|
|
and clock.absolute_minute == SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
|
and int(full.get(&"new_wages_total", -1)) == daily_wage
|
|
and int(full.get(&"paid_total", -1)) == daily_wage
|
|
and int(full.get(&"unpaid_after_total", -1)) == 0,
|
|
"Day 1 payroll is paid in full and closes at exactly 06:00"
|
|
)
|
|
|
|
var cash_now: int = runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT)
|
|
var drained: CommandResult = runtime.submit_command(
|
|
&"change_resources",
|
|
{&"deltas": {&"gold": -(cash_now - 50)}},
|
|
&"player",
|
|
&"",
|
|
90,
|
|
-1,
|
|
&"operations.acceptance.cash-drain"
|
|
)
|
|
_expect(
|
|
drained.is_success()
|
|
and runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) == 50,
|
|
"fixture leaves exactly 50 Gold for a deterministic partial payroll"
|
|
)
|
|
var partial_advanced := _advance_operations_boundary_slice(
|
|
fixture, SimulationKernel.MINUTES_PER_DAY
|
|
)
|
|
var partial: Dictionary = runtime.staff_service.get_payroll_settlement(&"payroll-day-00000002")
|
|
_expect(
|
|
partial_advanced == SimulationKernel.MINUTES_PER_DAY
|
|
and _operations_slice_is_healthy(fixture)
|
|
and clock.absolute_minute == 2 * SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
|
and int(partial.get(&"new_wages_total", -1)) == daily_wage
|
|
and int(partial.get(&"paid_total", -1)) == 50
|
|
and int(partial.get(&"unpaid_after_total", -1)) == daily_wage - 50,
|
|
"Day 2 payroll pays available Gold only and carries exact arrears"
|
|
)
|
|
|
|
var terminal_advanced := _advance_operations_boundary_slice(
|
|
fixture, 2 * SimulationKernel.MINUTES_PER_DAY
|
|
)
|
|
var coordinator: OperationsCoordinator = runtime.operations_coordinator
|
|
var last_three_negative := true
|
|
for day in [2, 3, 4]:
|
|
var summary: Dictionary = coordinator.get_day_summary(day)
|
|
last_three_negative = last_three_negative \
|
|
and int(summary.get(&"net_profit", 0)) < 0 \
|
|
and int((summary.get(&"settlement", {}) as Dictionary).get(
|
|
&"arrears_total", 0
|
|
)) > 0
|
|
_expect(
|
|
terminal_advanced == 2 * SimulationKernel.MINUTES_PER_DAY
|
|
and clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
|
and last_three_negative
|
|
and coordinator.get_consecutive_insolvency_count() == 3
|
|
and coordinator.get_terminal_state() == &"BANKRUPT",
|
|
"three consecutive negative closes with arrears enter BANKRUPT at the third boundary"
|
|
)
|
|
var terminal_snapshot: Dictionary = runtime.create_snapshot()
|
|
var terminal_digest := SimulationDigest.compute(terminal_snapshot)
|
|
_expect(
|
|
runtime.restore_snapshot(terminal_snapshot),
|
|
"BANKRUPT terminal v7 snapshot restores without clearing terminal state"
|
|
)
|
|
clock.advance_minutes(180)
|
|
_expect(
|
|
clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
|
and SimulationDigest.compute(runtime.create_snapshot()) == terminal_digest,
|
|
"BANKRUPT is terminal: later time requests are exact no-ops"
|
|
)
|
|
_expect(
|
|
not bool(runtime.acknowledge_operations_pause().get(&"ok", true)),
|
|
"BANKRUPT cannot be acknowledged away"
|
|
)
|
|
|
|
_assert_v7_tamper_rejected(
|
|
runtime,
|
|
terminal_snapshot,
|
|
func(tampered: Dictionary) -> void:
|
|
tampered[&"systems"][&"runtime"][&"operations"][&"personal_action_points"] = 99,
|
|
"v7 rejects an out-of-range Operations action-point total"
|
|
)
|
|
_assert_v7_tamper_rejected(
|
|
runtime,
|
|
terminal_snapshot,
|
|
func(tampered: Dictionary) -> void:
|
|
tampered[&"systems"][&"runtime"][&"next_command_id"] = int(
|
|
tampered[&"systems"][&"runtime"][&"next_command_id"]
|
|
) + 1,
|
|
"v7 rejects command high-water gaps"
|
|
)
|
|
_assert_v7_tamper_rejected(
|
|
runtime,
|
|
terminal_snapshot,
|
|
func(tampered: Dictionary) -> void:
|
|
var accepted: Array = tampered[&"systems"][&"runtime"][&"command_queue"][&"accepted"]
|
|
if not accepted.is_empty():
|
|
accepted[0][&"idempotency_key"] = &"tampered.accepted.key",
|
|
"v7 rejects accepted-command/idempotency cross-link tampering"
|
|
)
|
|
_assert_v7_tamper_rejected(
|
|
runtime,
|
|
terminal_snapshot,
|
|
func(tampered: Dictionary) -> void:
|
|
var summaries: Array = tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"day_summaries"]
|
|
summaries[0][&"category_totals"][&"wages"] = int(
|
|
summaries[0][&"category_totals"][&"wages"]
|
|
) + 1,
|
|
"v7 rejects DaySummary/payroll wage reconciliation tampering"
|
|
)
|
|
_assert_v7_tamper_rejected(
|
|
runtime,
|
|
terminal_snapshot,
|
|
func(tampered: Dictionary) -> void:
|
|
tampered[&"systems"][&"runtime"][&"unexpected_v7_field"] = true,
|
|
"v7 rejects unknown Runtime fields"
|
|
)
|
|
_assert_v7_tamper_rejected(
|
|
runtime,
|
|
terminal_snapshot,
|
|
func(tampered: Dictionary) -> void:
|
|
tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"unexpected_field"] = true,
|
|
"v7 rejects unknown Coordinator fields"
|
|
)
|
|
_free_fixture(fixture)
|
|
|
|
|
|
func _assert_v7_tamper_rejected(
|
|
runtime: Variant,
|
|
canonical: Dictionary,
|
|
mutator: Callable,
|
|
label: String
|
|
) -> void:
|
|
var before: Dictionary = runtime.create_snapshot()
|
|
var tampered: Dictionary = canonical.duplicate(true)
|
|
mutator.call(tampered)
|
|
var rejected: bool = not runtime.restore_snapshot(tampered)
|
|
_expect(rejected and runtime.create_snapshot() == before, label + " atomically")
|
|
|
|
|
|
func _test_fourteen_day_boundary_snapshot_determinism() -> void:
|
|
var seed := 73_003
|
|
var continuous := _new_fixture(seed)
|
|
var continuous_clock: Variant = continuous[&"clock"]
|
|
var continuous_runtime: Variant = continuous[&"runtime"]
|
|
var continuous_start: int = continuous_clock.absolute_minute
|
|
var first_advanced := _advance_operations_boundary_slice(
|
|
continuous, FOURTEEN_DAYS_MINUTES / 2
|
|
)
|
|
var midpoint: Dictionary = continuous_runtime.create_snapshot()
|
|
_expect(
|
|
first_advanced == FOURTEEN_DAYS_MINUTES / 2
|
|
and continuous_clock.absolute_minute == continuous_start + FOURTEEN_DAYS_MINUTES / 2
|
|
and _operations_slice_is_healthy(continuous),
|
|
"boundary-accelerated midpoint is captured after seven healthy days"
|
|
)
|
|
var second_advanced := _advance_operations_boundary_slice(
|
|
continuous, FOURTEEN_DAYS_MINUTES / 2
|
|
)
|
|
var continuous_snapshot: Dictionary = continuous_runtime.create_snapshot()
|
|
var continuous_digest := SimulationDigest.compute(continuous_snapshot)
|
|
_expect(
|
|
first_advanced + second_advanced == FOURTEEN_DAYS_MINUTES
|
|
and continuous_clock.absolute_minute - continuous_start == FOURTEEN_DAYS_MINUTES
|
|
and _operations_slice_is_healthy(continuous),
|
|
"continuous boundary-accelerated Operations advances exactly 14 days = 20,160 minutes"
|
|
)
|
|
_expect(
|
|
continuous_runtime.operations_coordinator.get_day_summaries().size() == 14,
|
|
"continuous boundary run commits all fourteen daily closes"
|
|
)
|
|
|
|
var resumed := _new_fixture(seed, false)
|
|
var resumed_clock: Variant = resumed[&"clock"]
|
|
var resumed_runtime: Variant = resumed[&"runtime"]
|
|
var restored: bool = resumed_runtime.restore_snapshot(midpoint)
|
|
_expect(restored, "fresh Runtime restores the enabled v7 midpoint snapshot")
|
|
var resumed_advanced := 0
|
|
if restored:
|
|
resumed_advanced = _advance_operations_boundary_slice(
|
|
resumed, FOURTEEN_DAYS_MINUTES / 2
|
|
)
|
|
var resumed_snapshot: Dictionary = resumed_runtime.create_snapshot()
|
|
_expect(
|
|
restored and resumed_advanced == FOURTEEN_DAYS_MINUTES / 2
|
|
and resumed_clock.absolute_minute == 6 * 60 + FOURTEEN_DAYS_MINUTES
|
|
and _operations_slice_is_healthy(resumed),
|
|
"restored boundary-accelerated Operations completes seven healthy days"
|
|
)
|
|
_expect(
|
|
restored and SimulationDigest.compute(resumed_snapshot) == continuous_digest
|
|
and resumed_snapshot == continuous_snapshot,
|
|
"continuous and midpoint-restored 14-day boundary runs have identical snapshot and Digest"
|
|
)
|
|
_free_fixture(continuous)
|
|
_free_fixture(resumed)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|