305 lines
16 KiB
GDScript
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()
|