Files
TheTree_mock/tests/helpers/p16_dismissal_fixture.gd

305 lines
16 KiB
GDScript

extends RefCounted
## P16-01-DISMISS shared scenes for tests/staff/test_general_dismissal.gd and its
## restart writer and reader. Every scene runs the production Runtime; a fixture
## only places employees, rescue states, Events and balances, never the dismissal.
const FRONT := &"PLAYER_CASH"
const BACK := &"BACK_CASH"
const FRONT_SALES := &"P16_FIXTURE_FRONT_SALES"
const BACK_SALES := &"P16_FIXTURE_BACK_SALES"
const PAYER := &"P16_FIXTURE_PAYER"
## The ledger day script (F4/F8): two hired general employees, a 00:00 close
## without funds (their first day's wages stay unpaid), sales in both funds,
## the dismissal of staff_akane at 01:00 of day 2 (severance 360 = 3 x 120: front
## 300, hidden 60) and the day 2 close that pays staff_eri and the departed
## obligation of staff_akane (120) once, the front fund topped up from the hidden one.
const LEDGER_SEED := 16_016
const DISMISSED := &"staff_akane"
const KEPT := &"staff_eri"
const DISMISS_MINUTE := 1500
const LAST_MINUTE := 2900
const SAVE_POINTS := [[1500, "before"], [1500, "after"], [1501, "before"], [2879, "before"], [2880, "before"], [2881, "before"]]
const RESCUE_SEED := 712
const HANDOFF_SCHEMA := "p16-01-dismiss-restart-v1"
const HANDOFF_PATH := "res://artifacts/p16-01-dismiss/restart-handoff.json"
const WRITER_SOURCE := "res://tests/staff/test_general_dismissal_restart_1_write.gd"
const WRITER_SAVE := "user://tests/p16-dismiss-writer-%s"
const READER_SAVE := "user://tests/p16-dismiss-reader-%s"
var _expect: Callable
func _init(expect: Callable) -> void:
_expect = expect
func check(condition: bool, message: String) -> void:
_expect.call(condition, message)
func new_fixture(seed: int, operations := false) -> 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()
check(bool(runtime.initialize(clock, state, seed).get(&"valid", false)), "fixture Runtime initializes (seed %d)" % seed)
clock.set_paused(true)
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
if operations:
check(bool(runtime.enable_production_operations().get(&"ok", false)), "fixture enables production Operations")
for account: StringName in [FRONT_SALES, BACK_SALES, PAYER]:
runtime.economy_service.register_account(account)
check(runtime.economy_service.initialize_account(PAYER, 100000, clock.absolute_minute, &"p16.fixture.payer").is_success(), "fixture payer funded")
return {&"clock": clock, &"state": state, &"runtime": runtime}
func free_fixture(fixture: Dictionary) -> void:
for key: StringName in [&"runtime", &"clock", &"state"]:
if fixture.get(key) != null:
fixture[key].free()
func command(fixture: Dictionary, type: StringName, payload: Dictionary, key: StringName = &"") -> CommandResult:
return fixture[&"runtime"].submit_command(type, payload, &"player", &"", 0, -1, key)
func dismiss(fixture: Dictionary, staff_id: StringName, key: StringName = &"") -> CommandResult:
return command(fixture, &"dismiss_staff", {&"staff_id": staff_id}, key)
## A disclosed balance fixture: a sale swept into the front or the hidden fund.
func sale(fixture: Dictionary, fund: StringName, amount: int, key: String) -> bool:
var runtime: Variant = fixture[&"runtime"]
var minute: int = fixture[&"clock"].absolute_minute
var revenue := FRONT_SALES if fund == FRONT else BACK_SALES
var entries: Array = [
LedgerEntry.new().configure(&"", minute, revenue, PAYER, amount, &"SERVICE_SALE", &"p16.fixture"),
LedgerEntry.new().configure(&"", minute, fund, revenue, amount, &"SERVICE_CASH_SWEEP", &"p16.fixture"),
]
return runtime.economy_service.post(entries, StringName("p16.sale." + key)).is_success()
## A disclosed balance fixture: an operating cost that empties the front fund.
func drain_front(fixture: Dictionary, key: String) -> bool:
var runtime: Variant = fixture[&"runtime"]
var cash: int = runtime.economy_service.get_balance(FRONT)
if cash <= 0:
return true
return runtime.economy_service.transfer(FRONT, &"OPERATING_EXPENSE", cash, fixture[&"clock"].absolute_minute,
&"OPERATING_EXPENSE", &"p16.fixture", StringName("p16.drain." + key)).is_success()
func posted(fixture: Dictionary, key: String) -> bool:
return not fixture[&"runtime"].economy_service.get_transaction_id_for_idempotency_key(StringName(key)).is_empty()
## The ledger day script from `from_minute` to `to_minute`. `on_point` is called
## with (minute, "before") ahead of the minute's scripted step and (minute, "after")
## behind it. Each step is skipped when its effect is already in the loaded game.
func play_ledger(fixture: Dictionary, from_minute: int, to_minute: int, on_point: Callable) -> void:
var clock: Variant = fixture[&"clock"]
var runtime: Variant = fixture[&"runtime"]
if int(clock.absolute_minute) != from_minute:
check(false, "ledger script starts at minute %d (clock %d)" % [from_minute, clock.absolute_minute])
return
while int(clock.absolute_minute) < to_minute:
var minute := int(clock.absolute_minute)
if on_point.is_valid():
on_point.call(minute, "before")
if minute == 362 and not runtime.staff_service.has_staff(DISMISSED):
for candidate: StringName in [DISMISSED, KEPT]:
check(command(fixture, &"hire_staff_candidate", {&"candidate_id": candidate}).is_success(), "hire %s through the ordinary command" % candidate)
if minute == 1430 and not posted(fixture, "p16.drain.close1"):
check(drain_front(fixture, "close1"), "the front fund is empty before the first close")
if minute == 1470 and not posted(fixture, "p16.sale.front2"):
check(sale(fixture, FRONT, 300, "front2") and sale(fixture, BACK, 400, "back2"), "day 2 sales: front 300, hidden 400")
if minute == DISMISS_MINUTE and runtime.staff_service.has_staff(DISMISSED):
var result := dismiss(fixture, DISMISSED, &"p16.ledger.dismiss")
check(result.is_success() and int(result.details.get(&"severance", -1)) == 360 and int(result.details.get(&"severance_top_up", -1)) == 60,
"the scripted dismissal pays 360, 60 of it from the hidden fund (%s %s)" % [result.message, result.details])
if on_point.is_valid():
on_point.call(minute, "after")
clock.advance_minutes(1)
func new_ledger_fixture() -> Dictionary:
return new_fixture(LEDGER_SEED, true)
func count_category(runtime: Variant, category: StringName, source: StringName = &"") -> int:
var count := 0
for entry: LedgerEntry in runtime.economy_service.get_entries():
if entry.category == category and (source.is_empty() or entry.source_entity_id == source):
count += 1
return count
func economy_bytes(runtime: Variant) -> PackedByteArray:
return var_to_bytes(runtime.economy_service.create_snapshot())
## The rescue elevator world of tests/staff/test_rescue_elevator.gd: an infirmary,
## a service elevator, a collapsed medic upstairs (patient) and a medic below.
## Both are ordinary FREE_HIRE employees with a daily wage of 100 (severance 300).
func new_rescue_fixture(seed := RESCUE_SEED) -> Dictionary:
var fixture := new_fixture(seed)
for spec: Array in [[&"infirmary", Vector3i(-6, 0, 1)], [&"service_elevator", Vector3i(0, 0, 1)]]:
check(command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]}).is_success(), "ordinary construction: %s" % spec[0])
fixture[&"clock"].advance_minutes(400)
for id: StringName in [&"patient", &"medic"]:
var options := {
&"current_cell": Vector3i(0, 1, 1) if id == &"patient" else Vector3i(5, 0, 1),
&"health": 0 if id == &"patient" else 100, &"state": StaffRuntime.State.AVAILABLE,
&"role_tags": [&"medic"], &"permitted_task_tags": [&"rescue"], &"daily_wage": 100,
}
check(command(fixture, &"register_staff", {&"staff_id": id, &"role_id": &"medic", &"options": options}).is_success(), "register rescue employee %s" % id)
return fixture
## The medic's rescue phase: task type, task state, elevator state and pairing.
func rescue_phase(runtime: Variant) -> String:
var medic: StaffRuntime = runtime.staff_service.get_staff(&"medic")
if medic == null:
return "gone"
var task: StaffTask = runtime.staff_service.get_task(medic.current_task_id)
var trip: Dictionary = runtime.elevators.get_actor_trip(&"staff:medic")
return "%s/%s/%s/%s" % [task.task_type if task != null else "-", StaffTask.State.keys()[task.state] if task != null else "-",
trip.get(&"state", "-"), "pair" if trip.has(&"companion_id") else "single"]
## Advances the rescue world minute by minute to the first minute of `phase`.
func advance_to_rescue_phase(fixture: Dictionary, phase: String, limit := 160) -> bool:
for tick: int in limit:
if rescue_phase(fixture[&"runtime"]) == phase:
return true
fixture[&"clock"].advance_minutes(1)
return rescue_phase(fixture[&"runtime"]) == phase
func register_event_definition(fixture: Dictionary, event_id: StringName, gold_cost: int) -> bool:
return command(fixture, &"register_event_definition", {&"definition": {
&"event_id": event_id, &"category": &"RELATIONSHIP", &"priority": 70, &"foreground": true,
&"transaction_policy": &"ROLLBACK_ALL", &"missing_actor_policy": &"CANCEL", &"content_rating": &"GENERAL",
&"choices": [{&"choice_id": &"purchase", &"text_key": StringName("event.%s.purchase" % event_id), &"cost": {&"gold": gold_cost},
&"actions": [{&"action_id": &"paid_choice_flag", &"type": &"SET_FLAG", &"payload": {&"flag_id": &"paid_choice_completed"}}]}],
}}).is_success()
## The Event scene of tests/s4/test_runtime_relationship_events.gd: a foreground
## Event that locks the employee and holds 80 Gold of reserved choice cost.
func new_event_fixture(seed: int) -> Dictionary:
var fixture := new_fixture(seed)
var runtime: Variant = fixture[&"runtime"]
check(command(fixture, &"register_staff", {&"staff_id": &"event_actor", &"role_id": &"service", &"options": {&"daily_wage": 80}}).is_success(), "register the Event actor")
check(register_event_definition(fixture, &"p16.event.actor", 80), "register the paid Event definition")
check(command(fixture, &"enqueue_event", {&"event_id": &"p16.event.actor", &"actor_ids": [&"event_actor"],
&"options": {&"event_instance_id": &"p16.event.instance"}}).is_success(), "enqueue the Event with the employee as its actor")
fixture[&"clock"].set_paused(false)
fixture[&"clock"].advance_one_minute()
var foreground: Variant = runtime.event_service.get_foreground_instance()
check(foreground != null and runtime.event_service.get_actor_locks().has(&"event_actor"), "the foreground Event locks the employee")
if foreground != null:
check(command(fixture, &"reserve_event_choice_cost", {&"event_instance_id": foreground.event_instance_id, &"choice_id": &"purchase"}).is_success(),
"the Event reserves its choice cost in escrow")
fixture[&"clock"].set_paused(true)
return fixture
## A foreground Event of two employees whose missing actor policy continues without
## the missing one (CONTINUE_WITHOUT).
func new_event_pair_fixture(seed: int) -> Dictionary:
var fixture := new_fixture(seed)
var runtime: Variant = fixture[&"runtime"]
for staff_id: StringName in [&"event_lead", &"event_partner"]:
check(command(fixture, &"register_staff", {&"staff_id": staff_id, &"role_id": &"service", &"options": {&"daily_wage": 80}}).is_success(), "register %s" % staff_id)
check(command(fixture, &"register_event_definition", {&"definition": {&"event_id": &"p16.event.pair", &"category": &"RELATIONSHIP", &"priority": 70,
&"foreground": true, &"transaction_policy": &"ROLLBACK_ALL", &"missing_actor_policy": &"CONTINUE_WITHOUT", &"content_rating": &"GENERAL",
&"choices": [{&"choice_id": &"wait", &"text_key": &"event.p16.pair.wait", &"actions": []}]}}).is_success()
and command(fixture, &"enqueue_event", {&"event_id": &"p16.event.pair", &"actor_ids": [&"event_lead", &"event_partner"],
&"options": {&"event_instance_id": &"p16.event.pair.instance"}}).is_success(), "a two-actor Event that continues without a missing actor")
fixture[&"clock"].set_paused(false)
fixture[&"clock"].advance_one_minute()
fixture[&"clock"].set_paused(true)
check(runtime.event_service.get_actor_locks().has(&"event_partner"), "the Event locks both employees")
return fixture
## Live references to a removed employee (SPEC 6.2/6.2.1): live Tasks, elevator
## trips, the path actor, facility staffing, Event locks and running Event actors.
## Terminal Task records keep their history.
func dangling_references(runtime: Variant, staff_id: StringName) -> Array[String]:
var found: Array[String] = []
if runtime.staff_service.has_staff(staff_id):
found.append("staff")
for task: StaffTask in runtime.staff_service.get_tasks():
if not task.is_terminal() and (task.target_entity_id == staff_id or task.assigned_staff_id == staff_id):
found.append("task " + String(task.task_id))
var actor := StringName("staff:" + String(staff_id))
if not runtime.elevators.get_actor_trip(actor).is_empty():
found.append("elevator trip")
for trip: Dictionary in runtime.elevators.get_report()[&"trips"]:
if StringName(trip.get(&"companion_id", &"")) == actor:
found.append("elevator companion")
if runtime.path_service.has_actor_projection(actor):
found.append("path actor")
for facility: FacilityInstance in runtime.facility_service.get_instances():
if staff_id in facility.active_staff_ids:
found.append("facility " + String(facility.instance_id))
if runtime.event_service.get_actor_locks().has(staff_id):
found.append("event lock")
for instance: EventInstance in runtime.event_service.get_instances():
if instance.state in [EventInstance.State.RUNNING, EventInstance.State.WAITING_CHOICE, EventInstance.State.INTERRUPTED] \
and staff_id in instance.actor_ids:
found.append("event actor " + String(instance.event_instance_id))
return found
## A transport-safe value of the state a dismissal touches, compared across
## processes (String keys, sorted, no StringName ordering).
func state_value(runtime: Variant, clock: Variant) -> Dictionary:
var staff := {}
for worker: StaffRuntime in runtime.staff_service.get_staff_members():
staff[String(worker.staff_id)] = [int(worker.state), var_to_str(worker.current_cell), String(worker.current_task_id), worker.unpaid_wages, worker.health]
var tasks := {}
for task: StaffTask in runtime.staff_service.get_tasks():
tasks[String(task.task_id)] = [String(task.task_type), int(task.state), String(task.state_reason), String(task.target_entity_id), String(task.assigned_staff_id), task.reservation_ids.size()]
var trips := {}
for trip: Dictionary in runtime.elevators.get_report()[&"trips"]:
trips[String(trip.get(&"actor_id", ""))] = [String(trip.get(&"state", "")), String(trip.get(&"companion_id", ""))]
var balances := {}
for account: Dictionary in runtime.economy_service.get_accounts():
balances[String(account[&"account_id"])] = int(account[&"balance"])
var departed := {}
for staff_id: StringName in runtime.staff_service.get_departed_staff_ids():
departed[String(staff_id)] = runtime.staff_service.get_departed_unpaid_wages(staff_id)
var locks := []
for actor_id: Variant in runtime.event_service.get_actor_locks():
locks.append(String(actor_id))
locks.sort()
var events := {}
for instance: EventInstance in runtime.event_service.get_instances():
events[String(instance.event_instance_id)] = [int(instance.state), instance.actor_ids.size()]
return {"minute": int(clock.absolute_minute), "staff": _sorted(staff), "tasks": _sorted(tasks), "trips": _sorted(trips),
"balances": _sorted(balances), "departed": _sorted(departed), "locks": locks, "events": _sorted(events),
"severance_entries": count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY), "wage_entries": count_category(runtime, &"WAGE")}
func _sorted(value: Dictionary) -> Array:
var keys := value.keys()
keys.sort()
var rows := []
for key: Variant in keys:
rows.append([key, value[key]])
return rows
static func sha256(bytes: PackedByteArray) -> String:
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
context.update(bytes)
return context.finish().hex_encode()