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()