P16-01-DISMISS: 一般従業員の通常操作での解雇(退職金を含む。P16-01 の補助の作業。原単位への加算 0)
This commit is contained in:
@@ -302,16 +302,24 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
|
||||
fields.append("elevators_v1_to_v2")
|
||||
proof["elevators"] = {"only_version_changed": same_otherwise}
|
||||
# P05-02 A12: a save from before the funds were split gains the five empty fund
|
||||
# accounts on load; every other economy field stays byte-identical.
|
||||
var has_back_cash := func(economy: Variant) -> bool:
|
||||
# accounts on load, and P16-01-DISMISS (A14) a save from before the severance gains
|
||||
# its empty expense account; every other economy field stays byte-identical.
|
||||
var has_account := func(economy: Variant, account_id: String) -> bool:
|
||||
if not economy is Dictionary:
|
||||
return false
|
||||
for account: Variant in (economy as Dictionary).get(&"accounts", []):
|
||||
if account is Dictionary and String((account as Dictionary).get(&"account_id", "")) == "BACK_CASH":
|
||||
if account is Dictionary and String((account as Dictionary).get(&"account_id", "")) == account_id:
|
||||
return true
|
||||
return false
|
||||
if not has_back_cash.call(old.get(&"economy")) and has_back_cash.call(now.get(&"economy")):
|
||||
var added := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true}
|
||||
var fund_accounts := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true}
|
||||
var added := {}
|
||||
if not has_account.call(old.get(&"economy"), "BACK_CASH") and has_account.call(now.get(&"economy"), "BACK_CASH"):
|
||||
added.merge(fund_accounts)
|
||||
var severance_added: bool = not has_account.call(old.get(&"economy"), "STAFF_SEVERANCE_EXPENSE") \
|
||||
and has_account.call(now.get(&"economy"), "STAFF_SEVERANCE_EXPENSE")
|
||||
if severance_added:
|
||||
added["STAFF_SEVERANCE_EXPENSE"] = false
|
||||
if not added.is_empty():
|
||||
var economy: Dictionary = (old[&"economy"] as Dictionary).duplicate(true)
|
||||
var accounts: Array = (economy[&"accounts"] as Array).duplicate(true)
|
||||
for account_id: String in added:
|
||||
@@ -321,8 +329,12 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
|
||||
var only_accounts_added: bool = var_to_bytes(economy) == var_to_bytes(now[&"economy"])
|
||||
ok = ok and only_accounts_added
|
||||
old[&"economy"] = economy
|
||||
fields.append("economy_A12_empty_fund_accounts")
|
||||
proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added}
|
||||
if added.has("BACK_CASH"):
|
||||
fields.append("economy_A12_empty_fund_accounts")
|
||||
proof["economy_A12"] = {"added_accounts": fund_accounts.keys(), "only_accounts_added": only_accounts_added}
|
||||
if severance_added:
|
||||
fields.append("economy_A14_empty_severance_account")
|
||||
proof["economy_A14"] = {"added_accounts": ["STAFF_SEVERANCE_EXPENSE"], "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
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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
|
||||
|
||||
|
||||
## 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dr2mdns7nvdoj
|
||||
@@ -0,0 +1,680 @@
|
||||
extends SceneTree
|
||||
const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd")
|
||||
const Scenes := preload("res://tests/helpers/p16_dismissal_fixture.gd")
|
||||
|
||||
## P16-01-DISMISS: dismissal of a general employee (FREE_HIRE) by the ordinary
|
||||
## dismiss_staff command, with the A14 severance (three days of the effective daily
|
||||
## wage from the front fund, the hidden fund covering the rest), the existing
|
||||
## removal contract, the refusals R1-R9 and the cleanup of SPEC 6.2/6.2.1 at the
|
||||
## command boundary. Every scene runs the production Runtime and its own rules;
|
||||
## fixtures only place employees, Tasks, rescue states, Events, raids and balances,
|
||||
## never the dismissal under test. The last scene dismisses from the game's own
|
||||
## Main staff panel (F9), so the runner gives this suite the ordinary Main audio
|
||||
## cleanup proof (tools/regression/suite-policy.json).
|
||||
|
||||
const FRONT := Scenes.FRONT
|
||||
const BACK := Scenes.BACK
|
||||
const RESCUE_PHASES := [
|
||||
"rescue_pickup/MOVING/-/single", "rescue_pickup/MOVING/QUEUED/single", "rescue_pickup/MOVING/BOARDED/single",
|
||||
"rescue_transport/MOVING/QUEUED/pair", "rescue_transport/MOVING/BOARDED/pair",
|
||||
"rescue_transport/MOVING/-/single", "rescue_transport/WORKING/-/single",
|
||||
]
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _scenes: Scenes
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("P16-01-DISMISS: general employee dismissal")
|
||||
_scenes = Scenes.new(_expect)
|
||||
_test_ledger_day()
|
||||
_test_top_up_and_insufficient_funds()
|
||||
_test_category_refusals()
|
||||
_test_queued_commands_for_the_same_employee()
|
||||
_test_bankrupt_refusal()
|
||||
_test_external_task_refusal()
|
||||
_test_reserved_task()
|
||||
_test_facility_duty_and_service()
|
||||
_test_walking_commute()
|
||||
_test_rescue_matrix()
|
||||
_test_event_actors()
|
||||
_test_defense_refusal()
|
||||
_test_commit_failure_rolls_back()
|
||||
_test_old_save_gains_the_severance_account()
|
||||
await _test_dismiss_from_main()
|
||||
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 _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
# --- SPEC 5: what a refusal may change -------------------------------------------
|
||||
|
||||
func _without(snapshot: Dictionary, keys: Array) -> Dictionary:
|
||||
var copy := snapshot.duplicate(true)
|
||||
var runtime_data: Dictionary = copy[&"systems"][&"runtime"]
|
||||
for key: Variant in keys:
|
||||
runtime_data.erase(key)
|
||||
return copy
|
||||
|
||||
|
||||
func _without_command_record(snapshot: Dictionary) -> Dictionary:
|
||||
return _without(snapshot, [&"next_command_id", &"command_queue", &"id_generator", &"domain_events"])
|
||||
|
||||
|
||||
## SPEC 5.c/5.d: the four command sections hold exactly the refused command's
|
||||
## record; every other section is byte-identical.
|
||||
func _only_command_record_added(before: Dictionary, after: Dictionary, command_id: int, result: CommandResult, key: StringName) -> bool:
|
||||
var b: Dictionary = before[&"systems"][&"runtime"]
|
||||
var a: Dictionary = after[&"systems"][&"runtime"]
|
||||
if int(a[&"next_command_id"]) != command_id + 1 or int(b[&"next_command_id"]) != command_id:
|
||||
return false
|
||||
var counters: Dictionary = (b[&"id_generator"]["counters"] as Dictionary).duplicate()
|
||||
counters["domain_event"] = int(counters.get("domain_event", 0)) + 1
|
||||
if a[&"id_generator"]["counters"] != counters:
|
||||
return false
|
||||
var bq: Dictionary = b[&"command_queue"]
|
||||
var aq: Dictionary = a[&"command_queue"]
|
||||
var processed: Array = aq["processed"]
|
||||
var accepted: Array = aq["accepted"]
|
||||
var idempotency: Dictionary = (bq["idempotency"] as Dictionary).duplicate()
|
||||
if not key.is_empty():
|
||||
idempotency[String(key)] = command_id
|
||||
if aq["pending"] != bq["pending"] or aq["in_flight"] != bq["in_flight"] or aq["idempotency"] != idempotency \
|
||||
or processed.size() != (bq["processed"] as Array).size() + 1 or processed.slice(0, processed.size() - 1) != bq["processed"] \
|
||||
or processed[-1] != result.to_dictionary() or accepted.size() != (bq["accepted"] as Array).size() + 1 \
|
||||
or accepted.slice(0, accepted.size() - 1) != bq["accepted"] or int(accepted[-1]["command_id"]) != command_id:
|
||||
return false
|
||||
var b_ids: Array = b[&"domain_events"]["published_ids"]
|
||||
var a_ids: Array = a[&"domain_events"]["published_ids"]
|
||||
var prior: Array = b[&"domain_events"]["recent_events"]
|
||||
var recent: Array = a[&"domain_events"]["recent_events"]
|
||||
var added: Dictionary = recent[-1]
|
||||
# The stream keeps its newest 256 events; the oldest one may leave it.
|
||||
var kept := recent.slice(0, recent.size() - 1)
|
||||
return a_ids.size() == b_ids.size() + 1 and a_ids.has(added["event_id"]) and not b_ids.has(added["event_id"]) \
|
||||
and added["event_type"] == "COMMAND_FAILED" and int(added["causation_command_id"]) == command_id \
|
||||
and kept == prior.slice(prior.size() - kept.size())
|
||||
|
||||
|
||||
func _expect_executor_refusal(fixture: Dictionary, staff_id: StringName, reason: StringName, label: String, key: StringName = &"") -> CommandResult:
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
var command_id := int(before[&"systems"][&"runtime"][&"next_command_id"])
|
||||
var result := _scenes.dismiss(fixture, staff_id, key)
|
||||
var after: Dictionary = runtime.create_snapshot()
|
||||
_expect(not result.is_success() and result.command_id == command_id and StringName(result.details.get(&"reason", &"")) == reason,
|
||||
"%s: refused with %s (%s %s)" % [label, reason, result.message, result.details])
|
||||
_expect(var_to_bytes(_without_command_record(before)) == var_to_bytes(_without_command_record(after)), "%s: every other section of the save is byte-identical (5.c)" % label)
|
||||
_expect(_only_command_record_added(before, after, command_id, result, key), "%s: only the command id, its failed result and one COMMAND_FAILED event were added (5.d)" % label)
|
||||
return result
|
||||
|
||||
|
||||
## SPEC 5.b: refused before the command is accepted; the whole save is byte-identical.
|
||||
func _expect_admission_refusal(fixture: Dictionary, staff_id: StringName, key: StringName, label: String) -> CommandResult:
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var before := var_to_bytes(runtime.create_snapshot())
|
||||
var result := _scenes.dismiss(fixture, staff_id, key)
|
||||
_expect(not result.is_success() and var_to_bytes(runtime.create_snapshot()) == before, "%s: refused before acceptance, the whole save is byte-identical (5.b)" % label)
|
||||
return result
|
||||
|
||||
|
||||
# --- SPEC 6.2: the cleanup at the command boundary --------------------------------
|
||||
|
||||
func _count_removed(runtime: Variant, staff_id: StringName) -> int:
|
||||
var count := 0
|
||||
for event in runtime.domain_events.recent_events():
|
||||
if event.event_type == &"STAFF_REMOVED" and staff_id in event.entity_ids:
|
||||
count += 1 if StringName(event.payload.get(&"reason", &"")) == &"DISMISSED" else 100
|
||||
return count
|
||||
|
||||
|
||||
func _expect_boundary(fixture: Dictionary, staff_id: StringName, label: String) -> void:
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var dangling: Array[String] = _scenes.dangling_references(runtime, staff_id)
|
||||
_expect(dangling.is_empty(), "%s: no live reference to the dismissed employee remains %s" % [label, dangling])
|
||||
_expect(_count_removed(runtime, staff_id) == 1, "%s: exactly one STAFF_REMOVED with reason DISMISSED" % label)
|
||||
_expect(runtime.staff_service.validate_invariants()[&"ok"], "%s: Staff and Task ownership stays consistent" % label)
|
||||
var orphans: Dictionary = runtime.staff_service.audit_orphan_tasks()
|
||||
_expect((orphans[&"reopened_task_ids"] as Array).is_empty() and (orphans[&"released_staff_ids"] as Array).is_empty()
|
||||
and (orphans[&"release_failed_task_ids"] as Array).is_empty(), "%s: audit_orphan_tasks finds no orphan" % label)
|
||||
_expect(runtime.economy_service.validate_ledger()[&"ok"], "%s: the ledger stays balanced" % label)
|
||||
var saved: Dictionary = runtime.create_snapshot()
|
||||
_expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "%s: a save right after the command restores exactly" % label)
|
||||
var slot := "user://tests/p16-dismiss-boundary-%d" % OS.get_process_id()
|
||||
var written: bool = runtime.save_to_path(slot).get(&"ok", false)
|
||||
var loaded: bool = runtime.load_from_path(slot).get(&"ok", false)
|
||||
SaveService.delete_save(slot)
|
||||
# An ordinary load publishes its own load event; nothing else differs.
|
||||
var events_only := [&"id_generator", &"domain_events"]
|
||||
_expect(written and loaded and var_to_bytes(_without(runtime.create_snapshot(), events_only)) == var_to_bytes(_without(saved, events_only)),
|
||||
"%s: the same save written to disk and loaded again is the same game" % label)
|
||||
|
||||
|
||||
# --- Scenes -------------------------------------------------------------------------
|
||||
|
||||
## A1, A10, F1, F3, F4, F5: the ledger day script with its 00:00 closes.
|
||||
func _test_ledger_day() -> void:
|
||||
var fixture := _scenes.new_ledger_fixture()
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_scenes.play_ledger(fixture, 360, Scenes.LAST_MINUTE, func(minute: int, phase: String) -> void:
|
||||
if minute != Scenes.DISMISS_MINUTE:
|
||||
return
|
||||
if phase == "before":
|
||||
var worker: StaffRuntime = runtime.staff_service.get_staff(Scenes.DISMISSED)
|
||||
_expect(worker != null and worker.employment_kind == StaffRuntime.EmploymentKind.FREE_HIRE and worker.current_task_id.is_empty()
|
||||
and worker.state == StaffRuntime.State.OFF_DUTY, "A1: the general employee is off duty with no Task before the dismissal")
|
||||
_expect(worker != null and worker.unpaid_wages == 120 and worker.get_effective_daily_wage() == 120, "A10: the first close left her daily wage 120 unpaid")
|
||||
var preview: Dictionary = runtime.get_staff_dismissal_preview(Scenes.DISMISSED)
|
||||
_expect(bool(preview[&"can_dismiss"]) and int(preview[&"severance"]) == 360 and int(preview[&"unpaid_wages"]) == 120
|
||||
and int(preview[&"front_cash"]) == 300 and int(preview[&"available_funds"]) == 700, "the preview shows severance 360, unpaid 120 and funds 300 + 400")
|
||||
return
|
||||
var cash_entries: Array = runtime.economy_service.get_entries().filter(func(entry: LedgerEntry) -> bool:
|
||||
return entry.source_entity_id == Scenes.DISMISSED and entry.absolute_minute == minute)
|
||||
_expect(cash_entries.size() == 2 and cash_entries[0].transaction_id == cash_entries[1].transaction_id, "F3: the top-up and the severance are one transaction")
|
||||
var categories := {}
|
||||
for entry: LedgerEntry in cash_entries:
|
||||
categories[entry.category] = [entry.debit_account, entry.credit_account, entry.amount, entry.absolute_minute]
|
||||
_expect(categories.get(FinancePolicy.TOP_UP_CATEGORY) == [FRONT, BACK, 60, minute]
|
||||
and categories.get(FinancePolicy.SEVERANCE_CATEGORY) == [FinancePolicy.SEVERANCE_EXPENSE, FRONT, 360, minute],
|
||||
"F3: hidden 60 tops up the front fund, which pays 360 to the severance expense at the command minute")
|
||||
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 340
|
||||
and runtime.economy_service.get_balance(FinancePolicy.SEVERANCE_EXPENSE) == 360 and int(fixture[&"state"].get_resource(&"gold")) == 0,
|
||||
"F3: front 0, hidden 340, severance expense 360 and the Gold mirror agree")
|
||||
_expect(not runtime.staff_service.has_staff(Scenes.DISMISSED) and runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 120,
|
||||
"F1/F4: she leaves the roster; her unpaid 120 is held apart from the severance")
|
||||
var first: CommandResult = runtime.command_queue.get_result_by_idempotency_key(&"p16.ledger.dismiss")
|
||||
_expect(first != null and first.is_success() and int(first.details[&"unpaid_wages_preserved"]) == 120 and (first.details[&"removed_shift_ids"] as Array).size() == 1,
|
||||
"4.2: the result lists the severance, the held wages and the removed Shift")
|
||||
_expect_boundary(fixture, Scenes.DISMISSED, "A1")
|
||||
var replay := _expect_admission_refusal(fixture, Scenes.DISMISSED, &"p16.ledger.dismiss", "F5 same key")
|
||||
_expect(replay.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY and (replay.details.get("original_result", {}) as Dictionary).get("details", {}).get(&"severance") == 360,
|
||||
"F5: the resent key returns DUPLICATE_IDEMPOTENCY_KEY with the first result")
|
||||
_expect_executor_refusal(fixture, Scenes.DISMISSED, &"NOT_FOUND", "R4 dismissed employee again", &"p16.ledger.dismiss.again")
|
||||
_expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1, "F5: the severance is paid once"))
|
||||
var day2: Dictionary = runtime.operations_coordinator.get_day_summary(2)
|
||||
_expect(not day2.is_empty() and int(day2[&"category_totals"][&"cost_of_goods"]) == 360 and int(day2[&"category_totals"][&"wages"]) == 90,
|
||||
"F3/F4: the day 2 close counts the severance as an operating cost and the kept employee's wage only (%s)" % [day2.get(&"category_totals", {})])
|
||||
_expect(runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0 and runtime.staff_service.get_departed_staff_ids().is_empty(),
|
||||
"F4: the day 2 00:00 close pays the held wages")
|
||||
var first_close := _payroll_payments(runtime, 1)
|
||||
var second_close := _payroll_payments(runtime, 2)
|
||||
_expect(int(first_close.get(Scenes.DISMISSED, 0)) == 0 and int(first_close.get(Scenes.KEPT, 0)) == 0
|
||||
and second_close == {Scenes.DISMISSED: 120, Scenes.KEPT: 180}, "F4: nothing is paid at the first close; the departed 120 is paid once, at the day 2 close (%s %s)" % [first_close, second_close])
|
||||
_expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and runtime.economy_service.validate_ledger()[&"ok"],
|
||||
"F3: one severance posting; the ledger is balanced after both closes")
|
||||
_expect(int(day2[&"settlement"][&"funds"][&"rank_tax_arrears"]) == 30 and runtime.operations_coordinator.get_arrears_total() == 30,
|
||||
"the hidden fund spent on the severance leaves 30 of the day 2 rank tax unpaid")
|
||||
var saved: Dictionary = runtime.create_snapshot()
|
||||
_expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "the game after the close, with the unpaid tax, restores exactly")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
func _payroll_payments(runtime: Variant, day: int) -> Dictionary:
|
||||
var paid := {}
|
||||
var settlement: Dictionary = runtime.staff_service.get_payroll_settlement(StringName("payroll-day-%08d" % day))
|
||||
for payment: Dictionary in settlement.get(&"input_payments", []):
|
||||
paid[StringName(payment[&"staff_id"])] = int(payment[&"amount"])
|
||||
return paid
|
||||
|
||||
|
||||
## F3 / R8: the hidden fund covers what the front fund cannot; a total one Gold
|
||||
## short refuses the dismissal and changes nothing.
|
||||
func _test_top_up_and_insufficient_funds() -> void:
|
||||
var fixture := _scenes.new_fixture(16_020, true)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_marc"}).is_success(), "hire staff_marc (daily wage 170)")
|
||||
_expect(_scenes.drain_front(fixture, "r8") and _scenes.sale(fixture, FRONT, 100, "r8.front"), "front 100, hidden 0")
|
||||
_expect_executor_refusal(fixture, &"staff_marc", &"INSUFFICIENT_FUNDS", "R8 total 100 of 510")
|
||||
_expect(_scenes.sale(fixture, BACK, 409, "r8.back"), "hidden 409")
|
||||
var refused := _expect_executor_refusal(fixture, &"staff_marc", &"INSUFFICIENT_FUNDS", "R8 total 509 of 510")
|
||||
_expect(int(refused.details[&"severance"]) == 510 and int(refused.details[&"available_funds"]) == 509, "R8: the refusal names the severance and the funds")
|
||||
_expect(_scenes.sale(fixture, BACK, 1, "r8.back.last"), "hidden 410")
|
||||
var result := _scenes.dismiss(fixture, &"staff_marc")
|
||||
_expect(result.is_success() and int(result.details[&"severance"]) == 510 and int(result.details[&"severance_top_up"]) == 410
|
||||
and runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 0,
|
||||
"F3: a total of exactly 510 pays it, 410 from the hidden fund")
|
||||
_expect_boundary(fixture, &"staff_marc", "F3 top-up")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## R1 UNIQUE, R7 ADULT_CONTRACT, R4 NOT_FOUND and a malformed payload.
|
||||
func _test_category_refusals() -> void:
|
||||
var fixture := _scenes.new_fixture(16_030)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var anri := _scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"heroine_anri"})
|
||||
_expect(anri.is_success() and runtime.staff_service.get_staff(&"heroine_anri").employment_kind == StaffRuntime.EmploymentKind.UNIQUE, "Anri joins as the approved UNIQUE employee")
|
||||
var unique := _expect_executor_refusal(fixture, &"heroine_anri", &"UNIQUE", "R1 UNIQUE")
|
||||
_expect(unique.message == "Unique Staff cannot be dismissed, sold or permanently removed.", "R1: the existing removal contract's text")
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"adult_contract", &"role_id": &"attendant",
|
||||
&"options": {&"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 100}}).is_success(), "register an ADULT_CONTRACT fixture")
|
||||
_expect_executor_refusal(fixture, &"adult_contract", &"NOT_GENERAL_EMPLOYEE", "R7 ADULT_CONTRACT")
|
||||
_expect_executor_refusal(fixture, &"nobody", &"NOT_FOUND", "R4 unknown employee")
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
var malformed := _scenes.command(fixture, &"dismiss_staff", {})
|
||||
_expect(not malformed.is_success() and malformed.code == CommandResult.Code.INVALID_PAYLOAD
|
||||
and var_to_bytes(_without_command_record(runtime.create_snapshot())) == var_to_bytes(_without_command_record(before)), "a dismissal without a Staff id is refused (5.c)")
|
||||
_expect(not bool(runtime.get_staff_dismissal_preview(&"heroine_anri")[&"can_dismiss"])
|
||||
and runtime.get_staff_dismissal_preview(&"adult_contract")[&"reason"] == &"NOT_GENERAL_EMPLOYEE", "the previews refuse the same rows")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A9: another command queued for the dismissed employee is refused after the
|
||||
## dismissal and changes nothing; a second queued dismissal pays nothing.
|
||||
func _test_queued_commands_for_the_same_employee() -> void:
|
||||
var fixture := _scenes.new_fixture(16_040)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success(), "hire staff_eri")
|
||||
var next_minute: int = fixture[&"clock"].absolute_minute + 1
|
||||
var assignment: CommandResult = runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": &"staff_eri", &"facility_id": &"", &"template_id": &"NIGHT"},
|
||||
&"player", &"staff_eri", 0, next_minute, &"p16.a9.assignment")
|
||||
var second: CommandResult = runtime.submit_command(&"dismiss_staff", {&"staff_id": &"staff_eri"}, &"player", &"staff_eri", 0, next_minute, &"p16.a9.second")
|
||||
_expect(assignment.is_success() and second.is_success() and (runtime.command_queue.create_snapshot()["pending"] as Array).size() == 2, "an assignment and a second dismissal wait for the next minute")
|
||||
_expect(_scenes.dismiss(fixture, &"staff_eri").is_success(), "A9: the dismissal runs before the queued commands")
|
||||
_expect_boundary(fixture, &"staff_eri", "A9")
|
||||
fixture[&"clock"].advance_minutes(1)
|
||||
var later: CommandResult = runtime.command_queue.get_processed_result(assignment.command_id)
|
||||
var again: CommandResult = runtime.command_queue.get_processed_result(second.command_id)
|
||||
_expect(later != null and not later.is_success() and StringName(later.details.get(&"reason", &"")) == &"STAFF_NOT_FOUND"
|
||||
and runtime.staff_service.get_work_assignment(&"staff_eri").is_empty(), "A9: the queued assignment is refused as STAFF_NOT_FOUND and creates nothing")
|
||||
_expect(again != null and not again.is_success() and StringName(again.details.get(&"reason", &"")) == &"NOT_FOUND"
|
||||
and _scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1, "A9: the queued second dismissal is NOT_FOUND and pays nothing")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## R5: after the game ends the command is refused before acceptance.
|
||||
func _test_bankrupt_refusal() -> void:
|
||||
var fixture := _scenes.new_fixture(16_050, true)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success(), "hire staff_eri")
|
||||
fixture[&"clock"].advance_minutes(1439 - int(fixture[&"clock"].absolute_minute))
|
||||
_expect(_scenes.drain_front(fixture, "r5"), "disclosed spend leaves no funds before the first close")
|
||||
fixture[&"clock"].advance_minutes(4320 - int(fixture[&"clock"].absolute_minute))
|
||||
_expect(runtime.operations_coordinator.is_terminal(), "three actual operating losses end the game (BANKRUPT)")
|
||||
var refused := _expect_admission_refusal(fixture, &"staff_eri", &"", "R5 BANKRUPT")
|
||||
_expect(StringName(refused.details.get(&"reason", &"")) == &"BANKRUPT" and runtime.staff_service.has_staff(&"staff_eri"), "R5: the existing BANKRUPT refusal keeps the employee")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## R2: an employee carrying supply logistics (an external operation) is kept until
|
||||
## the operation ends; then the same command dismisses her.
|
||||
func _test_external_task_refusal() -> void:
|
||||
var fixture := _scenes.new_fixture(16_060)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
for row: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||||
_expect(_scenes.command(fixture, &"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "build %s" % row[0])
|
||||
clock.advance_minutes(180)
|
||||
var source: StringName = &""
|
||||
var destination: StringName = &""
|
||||
for facility: FacilityInstance in runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"warehouse": source = facility.instance_id
|
||||
if facility.definition_id == &"kitchen": destination = facility.instance_id
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_gina"}).is_success()
|
||||
and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_gina", &"facility_id": source, &"template_id": &"DAY"}).is_success(),
|
||||
"hire the porter and assign her to the warehouse")
|
||||
for minute: int in 40:
|
||||
if runtime.staff_service.get_staff(&"staff_gina").state == StaffRuntime.State.AVAILABLE: break
|
||||
clock.advance_minutes(1)
|
||||
_expect(_scenes.command(fixture, &"purchase_supplies", {&"warehouse_id": source, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}).is_success()
|
||||
and _scenes.command(fixture, &"create_logistics_request", {&"request_id": &"supply.p16_dismiss", &"source_inventory_id": runtime.facility_service.get_instance(source).local_inventory_id,
|
||||
&"destination_inventory_id": runtime.facility_service.get_instance(destination).local_inventory_id, &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}}).is_success(),
|
||||
"purchase stock and request its delivery")
|
||||
var external := false
|
||||
for minute: int in 40:
|
||||
clock.advance_minutes(1)
|
||||
var worker: StaffRuntime = runtime.staff_service.get_staff(&"staff_gina")
|
||||
if runtime.staff_service.is_externally_driven_task(worker.current_task_id):
|
||||
external = true
|
||||
break
|
||||
_expect(external, "the porter takes the supply job, an external operation")
|
||||
var refused := _expect_executor_refusal(fixture, &"staff_gina", &"EXTERNAL_TASK", "R2 external operation")
|
||||
_expect(refused.message == "Finish the employee external operation before removal.", "R2: the existing removal contract's text")
|
||||
for minute: int in 60:
|
||||
if runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.DELIVERING: break
|
||||
clock.advance_minutes(1)
|
||||
_expect(runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.DELIVERING, "the porter carries the loaded cargo")
|
||||
_expect_executor_refusal(fixture, &"staff_gina", &"EXTERNAL_TASK", "R2 loaded cargo")
|
||||
for minute: int in 60:
|
||||
if runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.COMPLETED: break
|
||||
clock.advance_minutes(1)
|
||||
_expect(runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.COMPLETED, "the delivery completes")
|
||||
_expect(_scenes.dismiss(fixture, &"staff_gina").is_success(), "R2 is a wait: after the delivery the porter is dismissed")
|
||||
_expect_boundary(fixture, &"staff_gina", "after the external operation")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A5: a working Task with a tool Reservation is reopened and its Reservation
|
||||
## released; R3: a Reservation that cannot be released refuses the dismissal.
|
||||
func _test_reserved_task() -> void:
|
||||
var fixture := _scenes.new_fixture(16_070)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var worker_options := {&"state": StaffRuntime.State.AVAILABLE, &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT),
|
||||
&"role_tags": [&"worker"], &"permitted_task_tags": [&"work"], &"daily_wage": 100}
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker", &"role_id": &"worker", &"options": worker_options}).is_success()
|
||||
and _scenes.command(fixture, &"add_inventory_stack", {&"owner_inventory_id": &"staff.supplies", &"item_id": &"tool/snapshot_kit", &"quantity": 1}).is_success()
|
||||
and _scenes.command(fixture, &"create_staff_task", {&"task_id": &"tool_task", &"task_type": &"inspect", &"priority": 90, &"options": {
|
||||
&"target_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"required_role_tags": [&"worker"], &"required_items": {&"tool/snapshot_kit": 1},
|
||||
&"task_tags": [&"work"], &"estimated_minutes": 10}}).is_success(), "a worker, a tool and a Task that needs it")
|
||||
fixture[&"clock"].advance_one_minute()
|
||||
var task: StaffTask = runtime.staff_service.get_task(&"tool_task")
|
||||
_expect(task.state == StaffTask.State.WORKING and task.assigned_staff_id == &"tool_worker" and task.reservation_ids.size() == 1, "the worker works the Task holding the tool Reservation")
|
||||
var working: Dictionary = runtime.create_snapshot()
|
||||
var preview: Dictionary = runtime.get_staff_dismissal_preview(&"tool_worker")
|
||||
_expect(bool(preview[&"can_dismiss"]) and preview[&"reopened_task_ids"] == [&"tool_task"], "the preview names the Task the dismissal reopens")
|
||||
var reservation_id: StringName = task.reservation_ids[0]
|
||||
# Disclosed fixture: the tool is consumed while the Task still names its Reservation.
|
||||
_expect(bool(runtime.inventory_service.consume_reservation(reservation_id, &"p16.fixture.consume", fixture[&"clock"].absolute_minute).get(&"ok", false)), "fixture consumes the reserved tool")
|
||||
var refused := _expect_executor_refusal(fixture, &"tool_worker", &"RESERVATION_RELEASE_FAILED", "R3 unreleasable Reservation")
|
||||
_expect(refused.message == "Staff removal could not release Task Reservations.", "R3: the existing removal contract's text")
|
||||
_expect(runtime.restore_snapshot(working), "back to the working Task")
|
||||
var result := _scenes.dismiss(fixture, &"tool_worker")
|
||||
task = runtime.staff_service.get_task(&"tool_task")
|
||||
_expect(result.is_success() and result.details[&"reopened_task_ids"] == [&"tool_task"] and task.state == StaffTask.State.OPEN
|
||||
and task.assigned_staff_id.is_empty() and task.reservation_ids.is_empty() and task.state_reason == &"STAFF_REMOVED", "A5: the Task is reopened by the removal contract")
|
||||
_expect(runtime.inventory_service.available_quantity(&"staff.supplies", &"tool/snapshot_kit") == 1, "A5: its tool Reservation is released")
|
||||
var dismissed_task: Dictionary = task.create_snapshot()
|
||||
var dismissed_inventory: Dictionary = runtime.inventory_service.create_snapshot()
|
||||
_expect_boundary(fixture, &"tool_worker", "A5")
|
||||
# The reopened Task follows the existing removal contract exactly.
|
||||
_expect(runtime.restore_snapshot(working) and runtime.staff_service.remove_staff(&"tool_worker", &"DISMISSED", true, fixture[&"clock"].absolute_minute).is_success()
|
||||
and runtime.staff_service.get_task(&"tool_task").create_snapshot() == dismissed_task and runtime.inventory_service.create_snapshot() == dismissed_inventory,
|
||||
"A5: the Task and the inventory end as the existing removal contract leaves them")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A3 on duty and A4 mid-service at a staffed cafeteria.
|
||||
func _test_facility_duty_and_service() -> void:
|
||||
var fixture := _scenes.new_fixture(16_080)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
_expect(_scenes.command(fixture, &"build_facility", {&"definition_id": &"cafeteria", &"origin": Vector3i(-5, 0, WorldService.LAYER_FRONT)}).is_success(), "build a cafeteria")
|
||||
clock.advance_minutes(180)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instances()[0]
|
||||
_expect(bool(load("res://tests/fixtures/required_kitchen_setup.gd").ensure(runtime, clock, facility.instance_id, true).get(&"ok", false)), "the cafeteria's paid kitchen link with its physical kitchen worker")
|
||||
var start_minute: int = posmod(clock.absolute_minute, 1440)
|
||||
for staff_id: StringName in [&"duty_cook", &"duty_server"]:
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": staff_id, &"role_id": &"worker", &"options": {&"state": StaffRuntime.State.OFF_DUTY,
|
||||
&"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"daily_wage": 100}}).is_success()
|
||||
and _scenes.command(fixture, &"create_staff_shift", {&"shift_id": StringName("shift_%s" % staff_id), &"staff_id": staff_id, &"template_id": &"FLEX",
|
||||
&"start_day": int(clock.absolute_minute) / 1440, &"start_minute_of_day": start_minute, &"end_minute_of_day": start_minute + 300, &"facility_id": facility.instance_id}).is_success(),
|
||||
"%s works a Shift at the cafeteria" % staff_id)
|
||||
clock.advance_one_minute()
|
||||
_expect(facility.active_staff_ids == [&"duty_cook", &"duty_server"] and facility.state == FacilityInstance.State.OPEN, "both employees staff the open cafeteria")
|
||||
var on_duty: Dictionary = runtime.create_snapshot()
|
||||
_expect(_scenes.dismiss(fixture, &"duty_server").is_success(), "A3: the idle employee on duty is dismissed")
|
||||
_expect(facility.active_staff_ids == [&"duty_cook"], "A3: the facility staffing loses her at the command, not at the next minute")
|
||||
_expect_boundary(fixture, &"duty_server", "A3")
|
||||
_expect(runtime.restore_snapshot(on_duty), "back to both on duty")
|
||||
facility = runtime.facility_service.get_instance(facility.instance_id)
|
||||
_expect(_scenes.command(fixture, &"add_inventory_stack", {&"owner_inventory_id": facility.local_inventory_id, &"item_id": &"ingredient/basic_meal", &"quantity": 1}).is_success()
|
||||
and _scenes.command(fixture, &"register_guest_wallet", {&"guest_id": &"p16_guest", &"gold": 100}).is_success()
|
||||
and _scenes.command(fixture, &"create_service_request", {&"request_id": &"p16_service", &"facility_id": facility.instance_id, &"guest_ids": [&"p16_guest"],
|
||||
&"service_type": &"default", &"options": {&"quoted_price": 35}}).is_success(), "a guest requests a meal")
|
||||
clock.advance_one_minute()
|
||||
var active: Array = runtime.facility_service.get_active_services(facility.instance_id)
|
||||
_expect(active.size() == 1 and &"duty_server" in (active[0] as ServiceInstance).staff_ids, "A4: the service runs with her")
|
||||
if active.is_empty():
|
||||
_scenes.free_fixture(fixture)
|
||||
return
|
||||
var service_id: StringName = (active[0] as ServiceInstance).service_id
|
||||
_expect(_scenes.dismiss(fixture, &"duty_server").is_success(), "A4: the employee in the service is dismissed")
|
||||
_expect_boundary(fixture, &"duty_server", "A4")
|
||||
clock.advance_one_minute()
|
||||
var service: ServiceInstance = runtime.facility_service.get_service(service_id)
|
||||
_expect(service != null and service.state == ServiceInstance.State.WAITING_FOR_STAFF
|
||||
and service.staff_grace_deadline == clock.absolute_minute + FacilityService.STAFF_REPLACEMENT_GRACE_MINUTES, "A4: the service waits for replacement staff (existing rule)")
|
||||
clock.advance_minutes(FacilityService.STAFF_REPLACEMENT_GRACE_MINUTES)
|
||||
service = runtime.facility_service.get_service(service_id)
|
||||
_expect(service != null and service.state == ServiceInstance.State.FAILED and service.failure_reason == FacilityService.INTERRUPTION_STAFF_UNAVAILABLE,
|
||||
"A4: without a replacement the service fails as staff unavailable")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A2 on foot: an employee walking to her workplace is dismissed.
|
||||
func _test_walking_commute() -> void:
|
||||
var fixture := _scenes.new_fixture(16_090)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_scenes.command(fixture, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-8, 0, 0)}).is_success(), "build a reception")
|
||||
fixture[&"clock"].advance_minutes(180)
|
||||
var reception: StringName = runtime.facility_service.get_instances()[0].instance_id
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success()
|
||||
and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_akane", &"facility_id": reception, &"template_id": &"DAY"}).is_success(),
|
||||
"hire staff_akane and send her to the reception")
|
||||
fixture[&"clock"].advance_minutes(1)
|
||||
_expect(runtime.staff_service.get_staff(&"staff_akane").state == StaffRuntime.State.COMMUTING and runtime.path_service.has_actor_projection(&"staff:staff_akane"), "she is walking to work")
|
||||
_expect(_scenes.dismiss(fixture, &"staff_akane").is_success(), "A2: the commuting employee is dismissed")
|
||||
_expect_boundary(fixture, &"staff_akane", "A2 walking")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A2 at the elevator, A6 the carrier and A7 the patient, through every rescue
|
||||
## phase of the elevator rescue; a pair aboard is kept together until a landing.
|
||||
func _test_rescue_matrix() -> void:
|
||||
var fixture := _scenes.new_rescue_fixture()
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var checkpoints := {}
|
||||
for tick: int in 160:
|
||||
fixture[&"clock"].advance_minutes(1)
|
||||
var phase := _scenes.rescue_phase(runtime)
|
||||
if phase in RESCUE_PHASES and not checkpoints.has(phase):
|
||||
checkpoints[phase] = runtime.create_snapshot()
|
||||
_expect(checkpoints.size() == RESCUE_PHASES.size(), "the rescue passes every phase %s" % [checkpoints.keys()])
|
||||
for phase: String in RESCUE_PHASES:
|
||||
if not checkpoints.has(phase):
|
||||
continue
|
||||
for staff_id: StringName in [&"medic", &"patient"]:
|
||||
_expect(runtime.restore_snapshot(checkpoints[phase]), "restore %s" % phase)
|
||||
var label := "%s, dismiss the %s" % [phase, staff_id]
|
||||
if phase.ends_with("BOARDED/pair"):
|
||||
_expect_executor_refusal(fixture, staff_id, &"RESCUE_PAIR_ABOARD", label)
|
||||
continue
|
||||
var held: StringName = runtime.staff_service.get_staff(&"medic").current_task_id
|
||||
var result := _scenes.dismiss(fixture, staff_id)
|
||||
_expect(result.is_success(), "%s: dismissed (%s)" % [label, result.message])
|
||||
_expect_boundary(fixture, staff_id, label)
|
||||
var task: StaffTask = runtime.staff_service.get_task(held)
|
||||
if staff_id == &"medic" and phase.begins_with("rescue_pickup"):
|
||||
_expect(task.state == StaffTask.State.OPEN and task.assigned_staff_id.is_empty() and runtime.staff_service.get_staff(&"patient").state == StaffRuntime.State.INCAPACITATED,
|
||||
"%s: the pickup is reopened for another medic (A6)" % label)
|
||||
elif staff_id == &"medic":
|
||||
_expect(task.state == StaffTask.State.CANCELLED and task.state_reason == &"RESCUE_INTERRUPTED" and result.details[&"interrupted_rescue_task_ids"] == [held]
|
||||
and runtime.elevators.get_report()[&"trips"].is_empty(), "%s: the transport is interrupted with its elevator requests (A6)" % label)
|
||||
fixture[&"clock"].advance_minutes(1)
|
||||
var restarted: Array = runtime.staff_service.get_tasks().filter(func(t: StaffTask) -> bool: return t.task_type == &"rescue_pickup" and not t.is_terminal() and t.target_entity_id == &"patient")
|
||||
_expect(restarted.size() == 1, "%s: the next minute starts a new rescue for the patient (existing rule)" % label)
|
||||
else:
|
||||
var medic: StaffRuntime = runtime.staff_service.get_staff(&"medic")
|
||||
var aboard: bool = runtime.elevators.get_actor_trip(&"staff:medic").get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED
|
||||
_expect(task.state == StaffTask.State.CANCELLED and task.state_reason == &"RESCUE_INTERRUPTED" and medic.current_task_id.is_empty(),
|
||||
"%s: the rescue is interrupted and the medic released (A7)" % label)
|
||||
_expect(aboard == (phase == "rescue_pickup/MOVING/BOARDED/single"), "%s: a medic riding alone rides on to a landing; otherwise no elevator request remains" % label)
|
||||
fixture[&"clock"].advance_minutes(3)
|
||||
var later: Dictionary = runtime.create_snapshot()
|
||||
_expect(runtime.restore_snapshot(later) and runtime.staff_service.validate_invariants()[&"ok"], "%s: three minutes later the game still saves" % label)
|
||||
_expect(runtime.restore_snapshot(checkpoints.get("rescue_transport/MOVING/BOARDED/pair", runtime.create_snapshot())), "back to the pair aboard")
|
||||
_expect(_scenes.advance_to_rescue_phase(fixture, "rescue_transport/MOVING/-/single") and _scenes.dismiss(fixture, &"medic").is_success(),
|
||||
"the pair refusal is a wait: after the landing the medic is dismissed")
|
||||
_expect_boundary(fixture, &"medic", "after the landing")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A8: the Event's existing missing actor policy handles the dismissed actor at
|
||||
## the command boundary (CANCEL; CONTINUE_WITHOUT keeps the other actor).
|
||||
func _test_event_actors() -> void:
|
||||
var fixture := _scenes.new_event_fixture(16_100)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var cash_before: int = runtime.economy_service.get_balance(FRONT) + 80
|
||||
var result := _scenes.dismiss(fixture, &"event_actor")
|
||||
_expect(result.is_success() and int(result.details[&"severance"]) == 240, "A8: the locked Event actor is dismissed (severance 240)")
|
||||
var instance: EventInstance = runtime.event_service.get_instance(&"p16.event.instance")
|
||||
_expect(instance != null and instance.state == EventInstance.State.CANCELLED and runtime.event_service.get_foreground_instance() == null,
|
||||
"A8: the CANCEL policy cancels the Event at the command")
|
||||
_expect(runtime.economy_service.get_balance(&"EVENT_ESCROW:p16.event.instance") == 0 and runtime.economy_service.get_balance(FRONT) == cash_before - 240,
|
||||
"A8: the escrow is refunded once and the severance paid")
|
||||
_expect_boundary(fixture, &"event_actor", "A8 CANCEL")
|
||||
_scenes.free_fixture(fixture)
|
||||
fixture = _scenes.new_fixture(16_101)
|
||||
runtime = fixture[&"runtime"]
|
||||
for staff_id: StringName in [&"event_lead", &"event_partner"]:
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": staff_id, &"role_id": &"service", &"options": {&"daily_wage": 80}}).is_success(), "register %s" % staff_id)
|
||||
_expect(_scenes.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 _scenes.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)
|
||||
_expect(runtime.event_service.get_actor_locks().has(&"event_partner"), "the Event locks both employees")
|
||||
_expect(_scenes.dismiss(fixture, &"event_partner").is_success(), "A8: one actor of the continuing Event is dismissed")
|
||||
var pair: EventInstance = runtime.event_service.get_instance(&"p16.event.pair.instance")
|
||||
_expect(pair != null and pair.actor_ids == [&"event_lead"] and runtime.event_service.get_actor_locks().has(&"event_lead")
|
||||
and pair.state in [EventInstance.State.RUNNING, EventInstance.State.WAITING_CHOICE], "A8: CONTINUE_WITHOUT keeps the Event with the other actor")
|
||||
_expect_boundary(fixture, &"event_partner", "A8 CONTINUE_WITHOUT")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## R9 (Root's temporary boundary): the defender of an unfinished raid is kept;
|
||||
## after the raid ends the same employee is dismissed.
|
||||
func _test_defense_refusal() -> void:
|
||||
var fixture := _scenes.new_fixture(16_110)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_scenes.command(fixture, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-8, 0, 0)}).is_success(), "build the raid objective")
|
||||
fixture[&"clock"].advance_minutes(180)
|
||||
var objective: StringName = runtime.facility_service.get_instances()[0].instance_id
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_klaus"}).is_success(), "hire the guard staff_klaus")
|
||||
var created: CommandResult = runtime.submit_command(&"create_defense_raid", {&"raid_id": &"p16.raid", &"definition": {
|
||||
&"source_id": &"runtime_test", &"entry_points": [&"gate.north"], &"objective_ids": [objective], &"round_minutes": 5,
|
||||
&"max_duration_minutes": 180, &"retreat_policy": &"HOLD", &"defender_retreat_threshold": 0,
|
||||
&"waves": [{&"wave_id": &"wave.one", &"threat_strength": 15, &"retreat_threshold": 0, &"max_rounds": 1, &"entry_points": [&"gate.north"]}]}}, &"defense_controller")
|
||||
var defender: CommandResult = runtime.submit_command(&"register_defense_combatant", {&"raid_id": &"p16.raid", &"actor_id": &"staff_klaus",
|
||||
&"side": &"DEFENDER", &"combat": 90, &"max_health": 100, &"options": {}}, &"defense_controller")
|
||||
_expect(created.is_success() and defender.is_success(), "the guard defends an unfinished raid (%s %s)" % [created.message, defender.message])
|
||||
_expect_executor_refusal(fixture, &"staff_klaus", &"DEFENSE_LOCKED", "R9 defender of an unfinished raid")
|
||||
_expect(runtime.submit_command(&"cancel_defense_raid", {&"raid_id": &"p16.raid"}, &"defense_controller").is_success(), "the raid ends")
|
||||
_expect(_scenes.dismiss(fixture, &"staff_klaus").is_success(), "after the raid the former defender is dismissed")
|
||||
_expect_boundary(fixture, &"staff_klaus", "after the raid")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## SPEC 4.1 d: if the severance cannot be posted after the removal, the whole
|
||||
## dismissal is rolled back (5.c). Fixture: its posting key is already used.
|
||||
func _test_commit_failure_rolls_back() -> void:
|
||||
var fixture := _scenes.new_fixture(16_120)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_rika"}).is_success()
|
||||
and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_rika", &"facility_id": &"", &"template_id": &"DAY"}).is_success(), "hire staff_rika")
|
||||
var next_id := int(runtime.create_snapshot()[&"systems"][&"runtime"][&"next_command_id"])
|
||||
_expect(runtime.economy_service.transfer(Scenes.PAYER, Scenes.FRONT_SALES, 1, fixture[&"clock"].absolute_minute, &"TRANSFER", &"p16.fixture",
|
||||
StringName("staff.severance.%d" % next_id)).is_success(), "fixture occupies the dismissal's posting key")
|
||||
var refused := _expect_executor_refusal(fixture, &"staff_rika", &"SEVERANCE_POST_FAILED", "commit failure")
|
||||
_expect(runtime.staff_service.has_staff(&"staff_rika") and refused.code == CommandResult.Code.TRANSACTION_APPLY_FAILED, "the employee, her Shift and path actor are back")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A save from before the severance gains its empty expense account; an
|
||||
## incompatible account is refused and changes nothing.
|
||||
func _test_old_save_gains_the_severance_account() -> void:
|
||||
var fixture := _scenes.new_fixture(16_130, true)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
fixture[&"clock"].advance_minutes(60)
|
||||
var current: Dictionary = runtime.create_snapshot()
|
||||
var old := current.duplicate(true)
|
||||
var accounts: Array = old[&"systems"][&"runtime"][&"economy"][&"accounts"]
|
||||
var kept: Array = accounts.filter(func(account: Dictionary) -> bool: return String(account[&"account_id"]) != String(FinancePolicy.SEVERANCE_EXPENSE))
|
||||
_expect(kept.size() == accounts.size() - 1, "the current save holds the severance expense account")
|
||||
old[&"systems"][&"runtime"][&"economy"][&"accounts"] = kept
|
||||
_expect(runtime.restore_snapshot(old) and var_to_bytes(runtime.create_snapshot()) == var_to_bytes(current), "an older save gains only the empty severance account")
|
||||
var wrong := current.duplicate(true)
|
||||
for account: Dictionary in wrong[&"systems"][&"runtime"][&"economy"][&"accounts"]:
|
||||
if String(account[&"account_id"]) == String(FinancePolicy.SEVERANCE_EXPENSE):
|
||||
account[&"allow_negative"] = true
|
||||
_expect(not runtime.restore_snapshot(wrong) and var_to_bytes(runtime.create_snapshot()) == var_to_bytes(current), "an incompatible severance account is refused and changes nothing")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## F9: the game's own Runtime and Main. The staff panel shows the severance and
|
||||
## the reopened Task before the press, dismisses through its buttons, refuses the
|
||||
## UNIQUE and ADULT_CONTRACT rows with their reasons and sends nothing for them.
|
||||
func _test_dismiss_from_main() -> void:
|
||||
var runtime: Variant = root.get_node("SimulationRuntime")
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
var state: Variant = root.get_node("GameState")
|
||||
clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
runtime.initialize(clock, state, 16_140)
|
||||
clock.set_paused(true)
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Control = load("res://scenes/main/main.tscn").instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
await process_frame
|
||||
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
clock.set_paused(true)
|
||||
var key := InputEventKey.new()
|
||||
key.keycode = KEY_H
|
||||
key.pressed = true
|
||||
main.call("_unhandled_key_input", key)
|
||||
for candidate: StringName in [&"staff_akane", &"staff_eri", &"heroine_anri"]:
|
||||
var hire := (main.get("staff_candidate_buttons") as Dictionary).get(candidate) as Button
|
||||
_expect(hire != null, "the hiring panel lists %s" % candidate)
|
||||
if hire != null:
|
||||
hire.pressed.emit()
|
||||
_expect(runtime.submit_command(&"register_staff", {&"staff_id": &"adult_contract", &"role_id": &"attendant",
|
||||
&"options": {&"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 100}}).is_success(), "an ADULT_CONTRACT fixture row")
|
||||
_expect(runtime.submit_command(&"create_staff_task", {&"task_id": &"ui_task", &"task_type": &"inspect", &"priority": 90, &"options": {
|
||||
&"target_cell": runtime.staff_service.get_staff(&"staff_eri").current_cell, &"required_role_tags": [&"cleaner"], &"task_tags": [&"cleaning"], &"estimated_minutes": 120}}).is_success(),
|
||||
"a Task for the cleaner")
|
||||
clock.advance_minutes(1)
|
||||
var eri_task: StringName = runtime.staff_service.get_staff(&"staff_eri").current_task_id
|
||||
_expect(eri_task == &"ui_task", "the cleaner holds the Task (%s)" % eri_task)
|
||||
(main.get("staff_management_toggle_button") as Button).pressed.emit()
|
||||
var panel: StaffManagementPanel = main.get("staff_management_panel")
|
||||
_expect(panel.visible and panel.roster_buttons.size() == 4, "the hired-staff panel lists four employees")
|
||||
(panel.roster_buttons[&"staff_akane"] as Button).pressed.emit()
|
||||
_expect(not panel.dismiss_button.disabled and panel.dismiss_button.tooltip_text.contains("退職金 G360"), "the receptionist row offers the dismissal with severance G360")
|
||||
var untouched: Dictionary = runtime.create_snapshot()
|
||||
panel.dismiss_button.pressed.emit()
|
||||
_expect(panel.dismiss_confirm_box.visible and panel.dismiss_confirm_label.text.contains("退職金 G360(日給の3日分)") and runtime.create_snapshot() == untouched,
|
||||
"pressing 解雇する… shows the severance and sends nothing (5.a)")
|
||||
panel.dismiss_cancel_button.pressed.emit()
|
||||
_expect(not panel.dismiss_confirm_box.visible and runtime.create_snapshot() == untouched, "やめる closes the confirmation without a command")
|
||||
panel.dismiss_button.pressed.emit()
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
_expect(not runtime.staff_service.has_staff(&"staff_akane") and not panel.roster_buttons.has(&"staff_akane") and panel.selected_staff_id != &"staff_akane",
|
||||
"解雇を確定 dismisses her; the roster and the selection follow")
|
||||
_expect(panel.result_label.text.contains("解雇しました") and panel.result_label.text.contains("G360"), "the result shows the paid severance: %s" % panel.result_label.text)
|
||||
_expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY, &"staff_akane") == 1, "the panel's command paid the severance once")
|
||||
(panel.roster_buttons[&"staff_eri"] as Button).pressed.emit()
|
||||
panel.dismiss_button.pressed.emit()
|
||||
_expect(panel.dismiss_confirm_label.text.contains("持っている作業は開き直して") and panel.dismiss_confirm_label.text.contains("G270"), "the cleaner's confirmation names the reopened Task and G270")
|
||||
clock.set_paused(false)
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
_expect(runtime.staff_service.has_staff(&"staff_eri") and panel.dismiss_button.disabled and panel.result_label.text.contains("受理しました"), "with the clock running the dismissal waits for the next minute")
|
||||
clock.advance_minutes(1)
|
||||
clock.set_paused(true)
|
||||
_expect(not runtime.staff_service.has_staff(&"staff_eri") and runtime.staff_service.get_task(&"ui_task").state == StaffTask.State.OPEN
|
||||
and panel.result_label.text.contains("解雇しました"), "the queued dismissal completes and reopens her Task")
|
||||
for row: Array in [[&"heroine_anri", "ユニーク:雇用・維持費無料 / 解雇・売却不可"], [&"adult_contract", "解雇不可:ギルドで雇った一般従業員だけを解雇できます。"]]:
|
||||
(panel.roster_buttons[row[0]] as Button).pressed.emit()
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
_expect(panel.dismiss_button.disabled and panel.staff_detail_label.text.contains(row[1]), "%s: the button is disabled and the reason shown" % row[0])
|
||||
panel.dismiss_button.pressed.emit()
|
||||
_expect(runtime.create_snapshot() == before and not panel.dismiss_confirm_box.visible and runtime.staff_service.has_staff(row[0]), "%s: a forced press sends nothing (5.a)" % row[0])
|
||||
var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
|
||||
_expect(bool(cleanup.get(&"passed", false)), "ordinary Main release with audio cleanup")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbf85ttljo65h
|
||||
@@ -0,0 +1,116 @@
|
||||
extends SceneTree
|
||||
|
||||
## P16-01-DISMISS F7/F8, writer phase. Plays the ledger day script and writes
|
||||
## ordinary save files before and right after the dismissal command (before the
|
||||
## next minute), a minute later and around the day 2 00:00 close; and saves the
|
||||
## command boundary of A6 (the medic dismissed while carrying the patient on
|
||||
## foot), A7 (the patient dismissed while the medic rides the elevator alone) and
|
||||
## A8 (the actor of a foreground Event with reserved cost dismissed). The reader
|
||||
## suite (test_general_dismissal_restart_2_read.gd) loads them in a separate
|
||||
## engine process. The handoff is the declared generated report
|
||||
## res://artifacts/p16-01-dismiss/restart-handoff.json
|
||||
## (tools/regression/suite-policy.json fixed_generated_report_routes).
|
||||
|
||||
const Scenes := preload("res://tests/helpers/p16_dismissal_fixture.gd")
|
||||
const CONTINUE_MINUTES := 5
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _scenes: Scenes
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("P16-01-DISMISS: restart writer")
|
||||
_scenes = Scenes.new(_expect)
|
||||
var straight := _scenes.new_ledger_fixture()
|
||||
_scenes.play_ledger(straight, 360, Scenes.LAST_MINUTE, Callable())
|
||||
var straight_runtime: Variant = straight[&"runtime"]
|
||||
_expect(_scenes.count_category(straight_runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and straight_runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0,
|
||||
"uninterrupted script: one severance and the held wages paid at the day 2 close")
|
||||
var saving := _scenes.new_ledger_fixture()
|
||||
var ledger_saves: Array = []
|
||||
_scenes.play_ledger(saving, 360, Scenes.LAST_MINUTE, func(minute: int, phase: String) -> void:
|
||||
if [minute, phase] in Scenes.SAVE_POINTS:
|
||||
var name := "ledger-%d-%s" % [minute, phase]
|
||||
var path := Scenes.WRITER_SAVE % name
|
||||
_expect(bool(saving[&"runtime"].save_to_path(path).get(&"ok", false)), "save file %s" % name)
|
||||
ledger_saves.append(_package(name, path, {"minute": minute, "phase": phase})))
|
||||
_expect(_scenes.economy_bytes(saving[&"runtime"]) == _scenes.economy_bytes(straight_runtime), "writing the save files leaves the ledger as without saving")
|
||||
var boundaries: Array = []
|
||||
for scene: Array in [["A6", "rescue_transport/MOVING/-/single", &"medic"], ["A7", "rescue_pickup/MOVING/BOARDED/single", &"patient"]]:
|
||||
var fixture := _scenes.new_rescue_fixture()
|
||||
_expect(_scenes.advance_to_rescue_phase(fixture, scene[1]), "%s: the rescue reaches %s" % [scene[0], scene[1]])
|
||||
boundaries.append(_boundary(fixture, scene[0], scene[2]))
|
||||
_scenes.free_fixture(fixture)
|
||||
var event_fixture := _scenes.new_event_fixture(16_100)
|
||||
boundaries.append(_boundary(event_fixture, "A8", &"event_actor"))
|
||||
_scenes.free_fixture(event_fixture)
|
||||
var handoff := {
|
||||
"schema": Scenes.HANDOFF_SCHEMA,
|
||||
"writer_pid": OS.get_process_id(),
|
||||
"writer_source_sha256": FileAccess.get_sha256(Scenes.WRITER_SOURCE),
|
||||
"ledger_seed": Scenes.LEDGER_SEED,
|
||||
"last_minute": Scenes.LAST_MINUTE,
|
||||
"straight_economy_sha256": Scenes.sha256(_scenes.economy_bytes(straight_runtime)),
|
||||
"straight_state_base64": Marshalls.raw_to_base64(var_to_bytes(_scenes.state_value(straight_runtime, straight[&"clock"]))),
|
||||
"ledger_saves": ledger_saves,
|
||||
"boundaries": boundaries,
|
||||
}
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(Scenes.HANDOFF_PATH).get_base_dir())
|
||||
var file := FileAccess.open(Scenes.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("P16_RESTART_WRITER pid=%d handoff_sha256=%s" % [OS.get_process_id(), FileAccess.get_sha256(Scenes.HANDOFF_PATH)])
|
||||
_scenes.free_fixture(straight)
|
||||
_scenes.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)
|
||||
|
||||
|
||||
## Dismisses the employee, saves at once (the command boundary, before the next
|
||||
## minute), then records the state then and CONTINUE_MINUTES later.
|
||||
func _boundary(fixture: Dictionary, label: String, staff_id: StringName) -> Dictionary:
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var result := _scenes.dismiss(fixture, staff_id)
|
||||
_expect(result.is_success(), "%s: the employee is dismissed (%s)" % [label, result.message])
|
||||
var dangling: Array[String] = _scenes.dangling_references(runtime, staff_id)
|
||||
_expect(dangling.is_empty(), "%s: no live reference remains at the command boundary %s" % [label, dangling])
|
||||
var name := "boundary-%s" % label
|
||||
var path := Scenes.WRITER_SAVE % name
|
||||
_expect(bool(runtime.save_to_path(path).get(&"ok", false)), "%s: save right after the command" % label)
|
||||
var at_command := _scenes.state_value(runtime, fixture[&"clock"])
|
||||
fixture[&"clock"].advance_minutes(CONTINUE_MINUTES)
|
||||
var later := _scenes.state_value(runtime, fixture[&"clock"])
|
||||
return _package(name, path, {"label": label, "staff_id": String(staff_id), "continue_minutes": CONTINUE_MINUTES,
|
||||
"at_command_base64": Marshalls.raw_to_base64(var_to_bytes(at_command)), "later_base64": Marshalls.raw_to_base64(var_to_bytes(later))})
|
||||
|
||||
|
||||
func _package(name: String, path: String, extra: Dictionary) -> Dictionary:
|
||||
var directory := ProjectSettings.globalize_path(path)
|
||||
_expect(DirAccess.dir_exists_absolute(directory) and DirAccess.get_directories_at(directory).is_empty(), "save package %s holds only files" % name)
|
||||
var files := {}
|
||||
for file_name: String in DirAccess.get_files_at(directory):
|
||||
var bytes := FileAccess.get_file_as_bytes(directory.path_join(file_name))
|
||||
files[file_name] = {"sha256": Scenes.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" % name)
|
||||
var package := extra.duplicate(true)
|
||||
package["name"] = name
|
||||
package["files"] = files
|
||||
return package
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://df5vm2ir115eb
|
||||
@@ -0,0 +1,168 @@
|
||||
extends SceneTree
|
||||
|
||||
## P16-01-DISMISS F7/F8, reader phase. Runs after the writer suite
|
||||
## (test_general_dismissal_restart_1_write.gd) as a separate engine process. It
|
||||
## loads every ledger save, continues the day script and requires exactly the
|
||||
## uninterrupted ledger: one severance and one payment of the held wages,
|
||||
## whether the save was made before or right after the dismissal command or
|
||||
## around the day 2 00:00 close. It loads the A6, A7 and A8 command boundary
|
||||
## saves, requires the writer's state and no live reference to the dismissed
|
||||
## employee, and continues them to the writer's later state. A changed package
|
||||
## is refused.
|
||||
|
||||
const Scenes := preload("res://tests/helpers/p16_dismissal_fixture.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _scenes: Scenes
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("P16-01-DISMISS: restart reader")
|
||||
_scenes = Scenes.new(_expect)
|
||||
var handoff := _read_handoff()
|
||||
if not handoff.is_empty():
|
||||
_check_ledger(handoff)
|
||||
_check_boundaries(handoff)
|
||||
_check_tampered(handoff)
|
||||
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(Scenes.HANDOFF_PATH), "the writer phase left its handoff (run test_general_dismissal_restart_1_write.gd first)")
|
||||
if not FileAccess.file_exists(Scenes.HANDOFF_PATH):
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(Scenes.HANDOFF_PATH))
|
||||
_expect(parsed is Dictionary and str((parsed as Dictionary).get("schema", "")) == Scenes.HANDOFF_SCHEMA, "handoff schema")
|
||||
if not parsed is Dictionary or str((parsed as Dictionary).get("schema", "")) != Scenes.HANDOFF_SCHEMA:
|
||||
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(), "a separate engine process wrote the saves")
|
||||
_expect(str(handoff.get("writer_source_sha256", "")) == FileAccess.get_sha256(Scenes.WRITER_SOURCE), "the handoff comes from the current writer suite")
|
||||
_expect(int(handoff.get("ledger_seed", -1)) == Scenes.LEDGER_SEED and int(handoff.get("last_minute", -1)) == Scenes.LAST_MINUTE, "same fixture and day script")
|
||||
print("P16_RESTART_READER pid=%d writer_pid=%d handoff_sha256=%s" % [OS.get_process_id(), writer_pid, FileAccess.get_sha256(Scenes.HANDOFF_PATH)])
|
||||
return handoff
|
||||
|
||||
|
||||
func _decode(text: Variant) -> Variant:
|
||||
return bytes_to_var(Marshalls.base64_to_raw(str(text)))
|
||||
|
||||
|
||||
func _check_ledger(handoff: Dictionary) -> void:
|
||||
var straight := _scenes.new_ledger_fixture()
|
||||
_scenes.play_ledger(straight, 360, Scenes.LAST_MINUTE, Callable())
|
||||
var straight_runtime: Variant = straight[&"runtime"]
|
||||
var straight_economy := _scenes.economy_bytes(straight_runtime)
|
||||
var straight_state := _scenes.state_value(straight_runtime, straight[&"clock"])
|
||||
_expect(Scenes.sha256(straight_economy) == str(handoff.get("straight_economy_sha256", "")), "the uninterrupted day script gives the same ledger in both processes")
|
||||
_expect(straight_state == _decode(handoff.get("straight_state_base64", "")), "the uninterrupted day script gives the same employees, Tasks and balances in both processes")
|
||||
var saves: Array = handoff.get("ledger_saves", [])
|
||||
var points: Array = []
|
||||
for save: Variant in saves:
|
||||
points.append([int((save as Dictionary).get("minute", -1)), str((save as Dictionary).get("phase", ""))] if save is Dictionary else [])
|
||||
_expect(points == Scenes.SAVE_POINTS, "the writer saved before and right after the dismissal, a minute later and around the day 2 close")
|
||||
for save: Variant in saves:
|
||||
if not save is Dictionary:
|
||||
continue
|
||||
var minute := int((save as Dictionary)["minute"])
|
||||
var name := str((save as Dictionary)["name"])
|
||||
var path := Scenes.READER_SAVE % name
|
||||
if not _materialize(path, (save as Dictionary).get("files", {}), false):
|
||||
continue
|
||||
var resumed := _scenes.new_ledger_fixture()
|
||||
var runtime: Variant = resumed[&"runtime"]
|
||||
_expect(bool(runtime.load_from_path(path).get(&"ok", false)) and int(resumed[&"clock"].absolute_minute) == minute, "%s: this process loads the save at minute %d" % [name, minute])
|
||||
var dismissed_before: bool = not runtime.staff_service.has_staff(Scenes.DISMISSED)
|
||||
_expect(dismissed_before == (name != "ledger-1500-before"), "%s: the save holds the employee exactly when it was made before the command" % name)
|
||||
_scenes.play_ledger(resumed, minute, Scenes.LAST_MINUTE, Callable())
|
||||
_expect(_scenes.economy_bytes(runtime) == straight_economy, "%s: resuming gives exactly the uninterrupted ledger" % name)
|
||||
_expect(_scenes.state_value(runtime, resumed[&"clock"]) == straight_state, "%s: resuming gives the same employees, Tasks and balances" % name)
|
||||
_expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and runtime.economy_service.get_balance(FinancePolicy.SEVERANCE_EXPENSE) == 360,
|
||||
"%s: the severance is paid once" % name)
|
||||
_expect(runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0
|
||||
and int(runtime.operations_coordinator.get_day_summary(2)[&"settlement"][&"input_payments"][0][&"amount"]) == 120, "%s: the held 120 is paid once, at the day 2 close" % name)
|
||||
_scenes.free_fixture(resumed)
|
||||
_scenes.free_fixture(straight)
|
||||
|
||||
|
||||
func _check_boundaries(handoff: Dictionary) -> void:
|
||||
var boundaries: Array = handoff.get("boundaries", [])
|
||||
var labels: Array = []
|
||||
for boundary: Variant in boundaries:
|
||||
labels.append(str((boundary as Dictionary).get("label", "")) if boundary is Dictionary else "")
|
||||
_expect(labels == ["A6", "A7", "A8"], "the writer saved the A6, A7 and A8 command boundaries")
|
||||
for boundary: Variant in boundaries:
|
||||
if not boundary is Dictionary:
|
||||
continue
|
||||
var name := str((boundary as Dictionary)["name"])
|
||||
var staff_id := StringName(str((boundary as Dictionary)["staff_id"]))
|
||||
var path := Scenes.READER_SAVE % name
|
||||
if not _materialize(path, (boundary as Dictionary).get("files", {}), false):
|
||||
continue
|
||||
var fixture := _scenes.new_fixture(1)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(bool(runtime.load_from_path(path).get(&"ok", false)), "%s: this process loads the save made right after the command" % name)
|
||||
var dangling: Array[String] = _scenes.dangling_references(runtime, staff_id)
|
||||
_expect(dangling.is_empty(), "%s: no live reference to the dismissed employee after the load %s" % [name, dangling])
|
||||
_expect(runtime.staff_service.validate_invariants()[&"ok"] and runtime.economy_service.validate_ledger()[&"ok"], "%s: ownership and the ledger are consistent after the load" % name)
|
||||
_expect(_scenes.state_value(runtime, fixture[&"clock"]) == _decode((boundary as Dictionary)["at_command_base64"]), "%s: the loaded game is the writer's game at the command" % name)
|
||||
var saved: Dictionary = runtime.create_snapshot()
|
||||
_expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "%s: the loaded game saves and restores exactly" % name)
|
||||
fixture[&"clock"].advance_minutes(int((boundary as Dictionary)["continue_minutes"]))
|
||||
_expect(_scenes.state_value(runtime, fixture[&"clock"]) == _decode((boundary as Dictionary)["later_base64"]), "%s: it continues exactly as the writer's game" % name)
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
func _check_tampered(handoff: Dictionary) -> void:
|
||||
var saves: Array = handoff.get("ledger_saves", [])
|
||||
if saves.is_empty() or not saves[0] is Dictionary:
|
||||
_expect(false, "a save package to change")
|
||||
return
|
||||
var path := Scenes.READER_SAVE % "tampered"
|
||||
if not _materialize(path, (saves[0] as Dictionary).get("files", {}), true):
|
||||
return
|
||||
var fixture := _scenes.new_ledger_fixture()
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var before := _scenes.economy_bytes(runtime)
|
||||
_expect(not bool(runtime.load_from_path(path).get(&"ok", true)), "a changed save package is refused")
|
||||
_expect(_scenes.economy_bytes(runtime) == before and int(fixture[&"clock"].absolute_minute) == 360, "a refused load changes no Gold and no time")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
# Writes the writer's exact package bytes into this process's own profile.
|
||||
func _materialize(path: String, files: Variant, change_world: bool) -> bool:
|
||||
var directory := ProjectSettings.globalize_path(path)
|
||||
_expect(files is Dictionary and (files as Dictionary).has("manifest.json") and (files as Dictionary).has("world.bin"), "handoff package %s is complete" % path)
|
||||
if not files is Dictionary:
|
||||
return false
|
||||
_expect(DirAccess.make_dir_recursive_absolute(directory) == OK, "package directory %s" % path)
|
||||
for name: String in (files as Dictionary):
|
||||
var pin: Dictionary = (files as Dictionary)[name]
|
||||
var bytes := Marshalls.base64_to_raw(str(pin.get("base64", "")))
|
||||
_expect(Scenes.sha256(bytes) == str(pin.get("sha256", "")) and bytes.size() == int(pin.get("bytes", -1)), "handoff bytes of %s are exact" % name)
|
||||
if change_world and name == "world.bin" and not bytes.is_empty():
|
||||
bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0xff
|
||||
var file := FileAccess.open(directory.path_join(name), FileAccess.WRITE)
|
||||
_expect(file != null, "write %s into %s" % [name, path])
|
||||
if file == null:
|
||||
return false
|
||||
file.store_buffer(bytes)
|
||||
file.close()
|
||||
return true
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcgouseodo7h6
|
||||
Reference in New Issue
Block a user