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_ledger_saves_same_process() _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_cleaning_task() _test_facility_duty_and_service() _test_walking_commute() _test_rescue_matrix() _test_event_actors() _test_defense_refusal() _test_commit_failure_rolls_back() _test_rescue_cleanup_failure_rolls_back() _test_rollback_branches() _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 + 1, &"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 in the queue") _expect(_scenes.dismiss(fixture, &"staff_eri").is_success(), "A9: the dismissal runs before the queued commands") _expect_boundary(fixture, &"staff_eri", "A9") # Each queued command is processed by the Runtime's own command phase (_process_commands), # without the rest of the tick, so that its change alone is compared by SPEC 5.c. for queued: Array in [[assignment, next_minute, &"STAFF_NOT_FOUND", "the queued assignment"], [second, next_minute + 1, &"NOT_FOUND", "the queued second dismissal"]]: var before: Dictionary = runtime.create_snapshot() runtime.call("_process_commands", queued[1]) var after: Dictionary = runtime.create_snapshot() var processed: CommandResult = runtime.command_queue.get_processed_result((queued[0] as CommandResult).command_id) _expect(processed != null and not processed.is_success() and StringName(processed.details.get(&"reason", &"")) == queued[2], "A9: %s is refused as %s" % [queued[3], queued[2]]) _expect(processed != null and var_to_bytes(_without_command_record(before)) == var_to_bytes(_without_command_record(after)) and _only_queued_command_processed(before, after, processed), "A9: %s changes nothing but its own record (5.c)" % queued[3]) _expect(runtime.staff_service.get_work_assignment(&"staff_eri").is_empty() and _scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1, "A9: nothing is assigned and the severance is paid once") _scenes.free_fixture(fixture) ## SPEC 5.c for a command accepted earlier (A9): its processing takes no new command id ## and adds only its failed result and one COMMAND_FAILED event. func _only_queued_command_processed(before: Dictionary, after: Dictionary, result: CommandResult) -> bool: var b: Dictionary = before[&"systems"][&"runtime"] var a: Dictionary = after[&"systems"][&"runtime"] var counters: Dictionary = (b[&"id_generator"]["counters"] as Dictionary).duplicate() counters["domain_event"] = int(counters.get("domain_event", 0)) + 1 var bq: Dictionary = b[&"command_queue"] var aq: Dictionary = a[&"command_queue"] var pending: Array = (bq["pending"] as Array).filter(func(row: Dictionary) -> bool: return int(row["command_id"]) != result.command_id) var processed: Array = (aq["processed"] as Array).duplicate() var index := processed.find(result.to_dictionary()) if index >= 0: processed.remove_at(index) 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] var kept := recent.slice(0, recent.size() - 1) return int(a[&"next_command_id"]) == int(b[&"next_command_id"]) and a[&"id_generator"]["counters"] == counters \ and (bq["pending"] as Array).size() == pending.size() + 1 and aq["pending"] == pending and aq["in_flight"] == bq["in_flight"] \ and aq["idempotency"] == bq["idempotency"] and aq["accepted"] == bq["accepted"] and index >= 0 and processed == bq["processed"] \ and a_ids.size() == b_ids.size() + 1 and added["event_type"] == "COMMAND_FAILED" and int(added["causation_command_id"]) == result.command_id \ and kept == prior.slice(prior.size() - kept.size()) ## 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") var loaded: Dictionary = runtime.create_snapshot() # The return stage (返送), as tests/supply/test_supply_logistics_bridge.gd reaches it: # disclosed fixture splitting the cargo into two lots, one spoiling next minute. var inventory: Dictionary = runtime.inventory_service.create_snapshot() for owner: Dictionary in inventory[&"inventories"]: if owner[&"owner_inventory_id"] == "cargo:staff_gina": owner[&"stacks"][0][&"quantity"] = 4 _expect(runtime.inventory_service.restore_snapshot(inventory), "fixture splits the loaded cargo") runtime.inventory_service.add_stack(&"cargo:staff_gina", SupplyCatalog.RAW_FOOD, 4, 100, clock.absolute_minute + 1, clock.absolute_minute) clock.advance_minutes(1) var porter_task: StaffTask = runtime.staff_service.get_task(runtime.staff_service.get_staff(&"staff_gina").current_task_id) _expect(porter_task != null and porter_task.task_type == SupplyLogisticsBridge.RETURN_TYPE, "the porter returns the surviving cargo to its source") _expect_executor_refusal(fixture, &"staff_gina", &"EXTERNAL_TASK", "R2 return") _expect(runtime.restore_snapshot(loaded), "back to the loaded delivery") 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") # F7: the reopened reserved Task is taken again by another worker and completed, also when # the first reservation, the dismissal and the new reservation fall in one minute. var first_minute := int(working[&"clock"][&"absolute_minute"]) var first_reservation: StringName = reservation_id _expect(runtime.restore_snapshot(working) and int(fixture[&"clock"].absolute_minute) == first_minute, "back to the Task reserved at minute %d" % first_minute) _expect(_scenes.dismiss(fixture, &"tool_worker").is_success() and _scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker_2", &"role_id": &"worker", &"options": worker_options}).is_success(), "dismiss the worker and register another one in the same minute") runtime.staff_service.dispatch_now(first_minute) task = runtime.staff_service.get_task(&"tool_task") var second_reservation: StringName = task.reservation_ids[0] if task.reservation_ids.size() == 1 else &"" var reservation: InventoryReservation = runtime.inventory_service.get_reservation(second_reservation) _expect(task.assigned_staff_id == &"tool_worker_2" and second_reservation != first_reservation and reservation != null and reservation.is_active() and reservation.created_at == first_minute and runtime.inventory_service.get_reservation(first_reservation).state == InventoryReservation.State.RELEASED, "F7: in the same minute the other worker reserves the tool anew; the closed reservation is not replayed") fixture[&"clock"].advance_minutes(15) _expect(runtime.staff_service.get_task(&"tool_task").state == StaffTask.State.COMPLETED and runtime.inventory_service.available_quantity(&"staff.supplies", &"tool/snapshot_kit") == 1, "F7: the other worker completes the reopened Task and its tool is released again") _expect(runtime.restore_snapshot(working), "back to the reserved Task once more") _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker_2", &"role_id": &"worker", &"options": worker_options}).is_success(), "another worker is ready") fixture[&"clock"].set_paused(false) var queued: CommandResult = runtime.submit_command(&"dismiss_staff", {&"staff_id": &"tool_worker"}, &"player", &"tool_worker", 0, -1, &"p16.a5.queued") fixture[&"clock"].advance_one_minute() fixture[&"clock"].set_paused(true) task = runtime.staff_service.get_task(&"tool_task") var done: CommandResult = runtime.command_queue.get_processed_result(queued.command_id) _expect(done != null and done.is_success() and task.assigned_staff_id == &"tool_worker_2" and task.reservation_ids.size() == 1 and runtime.inventory_service.get_reservation(task.reservation_ids[0]).created_at == int(fixture[&"clock"].absolute_minute), "F7: a queued dismissal and the new reservation fall in the same tick") fixture[&"clock"].advance_minutes(15) _expect(runtime.staff_service.get_task(&"tool_task").state == StaffTask.State.COMPLETED, "F7: that Task is completed too") _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) _test_elevator_commute() ## A2 at the elevator: a cleaner commuting to the cleaning room upstairs waits for ## and rides the service elevator; she is dismissed at each of those phases. func _test_elevator_commute() -> void: var fixture := _scenes.new_fixture(16_091) var runtime: Variant = fixture[&"runtime"] var clock: Variant = fixture[&"clock"] for spec: Array in [[&"service_elevator", Vector3i(0, 0, 1)], [&"cleaning_room", Vector3i(3, 1, 1)]]: _expect(_scenes.command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]}).is_success(), "build %s" % spec[0]) clock.advance_minutes(400) var room: StringName = &"" for facility: FacilityInstance in runtime.facility_service.get_instances(): if facility.definition_id == &"cleaning_room": room = facility.instance_id _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success() and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_eri", &"facility_id": room, &"template_id": &"DAY"}).is_success(), "hire staff_eri and send her to the cleaning room upstairs") var checkpoints := {} for minute: int in 60: clock.advance_minutes(1) var worker: StaffRuntime = runtime.staff_service.get_staff(&"staff_eri") var trip: Dictionary = runtime.elevators.get_actor_trip(&"staff:staff_eri") if worker.state == StaffRuntime.State.COMMUTING and not trip.is_empty() and not checkpoints.has(trip[&"state"]): checkpoints[trip[&"state"]] = runtime.create_snapshot() if worker.state != StaffRuntime.State.COMMUTING: break _expect(checkpoints.has(PathService.ELEVATOR_REQUEST_QUEUED) and checkpoints.has(PathService.ELEVATOR_REQUEST_BOARDED), "her commute waits for and rides the elevator %s" % [checkpoints.keys()]) for phase: Variant in checkpoints: _expect(runtime.restore_snapshot(checkpoints[phase]), "restore the commute %s" % phase) _expect(runtime.staff_service.get_staff(&"staff_eri").state == StaffRuntime.State.COMMUTING, "she is commuting (%s)" % phase) _expect(_scenes.dismiss(fixture, &"staff_eri").is_success(), "A2: the commuting employee is dismissed at the elevator (%s)" % phase) _expect(runtime.elevators.get_report()[&"trips"].is_empty(), "A2: her elevator request ends with her (%s)" % phase) _expect_boundary(fixture, &"staff_eri", "A2 elevator %s" % phase) clock.advance_minutes(3) _expect(runtime.restore_snapshot(runtime.create_snapshot()), "A2: the elevator keeps working after the dismissal (%s)" % phase) _scenes.free_fixture(fixture) ## A6 the carrier and A7 the patient, through every rescue phase of the elevator ## rescue, the pair aboard included: the dismissed member leaves the cabin at the ## command and the other one rides on to a real landing (SPEC 6.2.1). 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()]) var stops: Array = runtime.elevators.get_report()[&"shafts"][0][&"stops"] 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] var other: StringName = &"patient" if staff_id == &"medic" else &"medic" var held: StringName = runtime.staff_service.get_staff(&"medic").current_task_id var other_aboard_before := _aboard(runtime, other) var preview: Dictionary = runtime.get_staff_dismissal_preview(staff_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) _expect(preview[&"reopened_task_ids"] == [held] and (preview[&"interrupted_rescue_task_ids"] as Array).is_empty() and result.details[&"reopened_task_ids"] == [held], "%s: the preview and the result call the pickup reopened" % 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], "%s: the transport is interrupted (A6)" % label) _expect(preview[&"interrupted_rescue_task_ids"] == [held] and preview[&"interrupted_rescue_roles"] == [&"carrier"] and (preview[&"reopened_task_ids"] as Array).is_empty() and (result.details[&"reopened_task_ids"] as Array).is_empty(), "%s: the preview and the result call the transport interrupted, not reopened" % label) else: _expect(task.state == StaffTask.State.CANCELLED and task.state_reason == &"RESCUE_INTERRUPTED" and runtime.staff_service.get_staff(&"medic").current_task_id.is_empty(), "%s: the rescue is interrupted and the medic released (A7)" % label) _expect(preview[&"interrupted_rescue_task_ids"] == [held] and preview[&"interrupted_rescue_roles"] == [&"patient"], "%s: the preview names the interrupted rescue" % label) # The other one rides on to a real landing when aboard; otherwise no elevator request remains. var other_trip: Dictionary = runtime.elevators.get_actor_trip(StringName("staff:" + String(other))) if other_aboard_before: _expect(other_trip.get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED and not other_trip.has(&"companion_id"), "%s: the %s stays aboard alone" % [label, other]) for minute: int in 40: if runtime.elevators.get_actor_trip(StringName("staff:" + String(other))).is_empty(): break fixture[&"clock"].advance_minutes(1) _expect(runtime.elevators.get_actor_trip(StringName("staff:" + String(other))).is_empty() and runtime.staff_service.get_staff(other).current_cell in stops, "%s: the %s alights at a real landing" % [label, other]) else: _expect(other_trip.is_empty(), "%s: no elevator request remains for the %s" % [label, other]) if staff_id == &"medic" and not phase.begins_with("rescue_pickup"): 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 patient's rescue starts again (existing rule)" % label) fixture[&"clock"].advance_minutes(2) var later: Dictionary = runtime.create_snapshot() _expect(runtime.restore_snapshot(later) and runtime.staff_service.validate_invariants()[&"ok"], "%s: later the game still saves" % label) _scenes.free_fixture(fixture) func _aboard(runtime: Variant, staff_id: StringName) -> bool: return runtime.elevators.get_actor_trip(StringName("staff:" + String(staff_id))).get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED ## 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) ## A5 with the SPEC's example: the cleaning Task (facility_clean) a cleaner works is ## reopened by the dismissal, and the other cleaner takes it and completes it. func _test_cleaning_task() -> void: var fixture := _scenes.new_fixture(16_075) var runtime: Variant = fixture[&"runtime"] var clock: Variant = fixture[&"clock"] for spec: Array in [[&"cleaning_room", Vector3i(-6, 0, 1)], [&"reception", Vector3i(1, 0, 0)]]: _expect(_scenes.command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]}).is_success(), "build %s" % spec[0]) clock.advance_minutes(240) var room: StringName = &"" var reception: StringName = &"" for facility: FacilityInstance in runtime.facility_service.get_instances(): if facility.definition_id == &"cleaning_room": room = facility.instance_id if facility.definition_id == &"reception": reception = facility.instance_id _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success() and _scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success() and _scenes.command(fixture, &"register_staff", {&"staff_id": &"spare_cleaner", &"role_id": &"cleaner", &"options": {&"role_tags": [&"cleaner"], &"permitted_task_tags": [&"cleaning", &"maintenance"], &"daily_wage": 90}}).is_success(), "two cleaners and a receptionist") for row: Array in [[&"staff_eri", room], [&"spare_cleaner", room], [&"staff_akane", reception]]: _expect(_scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": row[0], &"facility_id": row[1], &"template_id": &"DAY"}).is_success(), "assign %s" % row[0]) clock.advance_minutes(30) # 25 completed guest visits soil the reception to the cleaning threshold (75). var count := 0 for batch: int in 4: for index: int in (8 if batch < 3 else 1): var guest := StringName("p16_cleaning_payer_%d" % count) _expect(_scenes.command(fixture, &"register_guest_wallet", {&"guest_id": guest, &"gold": 100}).is_success() and _scenes.command(fixture, &"create_service_request", {&"request_id": StringName("p16_cleaning_visit_%d" % count), &"facility_id": reception, &"guest_ids": [guest], &"service_type": &"check_in"}).is_success(), "a real reception visit") count += 1 for minute: int in 25: clock.advance_one_minute() if int(runtime.facility_cleanliness.get_facility(reception)["current"]) == 100 - count: break var task: StaffTask = null for minute: int in 80: for candidate: StaffTask in runtime.staff_service.get_tasks(): if candidate.task_type == FacilityCleaningBridge.TASK_TYPE and candidate.target_entity_id == reception and not candidate.is_terminal(): task = candidate if task != null and task.state == StaffTask.State.WORKING: break clock.advance_one_minute() _expect(task != null and task.state == StaffTask.State.WORKING, "a cleaner works the reception's cleaning Task") if task == null: _scenes.free_fixture(fixture) return var task_id := task.task_id var worker := task.assigned_staff_id var other: StringName = &"spare_cleaner" if worker == &"staff_eri" else &"staff_eri" var result := _scenes.dismiss(fixture, worker) task = runtime.staff_service.get_task(task_id) _expect(result.is_success() and result.details[&"reopened_task_ids"] == [task_id] and task.state == StaffTask.State.OPEN and task.assigned_staff_id.is_empty() and task.state_reason == &"STAFF_REMOVED", "A5: the cleaning Task is reopened by the dismissal") _expect_boundary(fixture, worker, "A5 cleaning") var taker: StringName = &"" for minute: int in 80: clock.advance_one_minute() task = runtime.staff_service.get_task(task_id) if not task.assigned_staff_id.is_empty(): taker = task.assigned_staff_id if task.state == StaffTask.State.COMPLETED: break _expect(taker == other and task.state == StaffTask.State.COMPLETED and int(runtime.facility_cleanliness.get_facility(reception)["current"]) == 100, "A5: the other cleaner takes the reopened cleaning Task and completes it (cleanliness 100)") _scenes.free_fixture(fixture) ## F8 in the same process: every ledger save point (before and right after the ## dismissal, a minute later and around the day 2 close) is written to disk, read back ## by this process and played to the end, exactly as without the save. func _test_ledger_saves_same_process() -> void: var straight := _scenes.new_ledger_fixture() _scenes.play_ledger(straight, 360, Scenes.LAST_MINUTE, Callable()) var straight_economy := _scenes.economy_bytes(straight[&"runtime"]) var straight_state := _scenes.state_value(straight[&"runtime"], straight[&"clock"]) var saving := _scenes.new_ledger_fixture() var paths := {} _scenes.play_ledger(saving, 360, Scenes.LAST_MINUTE, func(minute: int, phase: String) -> void: if [minute, phase] in Scenes.SAVE_POINTS: var path := "user://tests/p16-same-process-%d-%s" % [minute, phase] _expect(bool(saving[&"runtime"].save_to_path(path).get(&"ok", false)), "same process: save at %d %s" % [minute, phase]) paths["%d-%s" % [minute, phase]] = path) for point: Array in Scenes.SAVE_POINTS: var name := "%d-%s" % [point[0], point[1]] var resumed := _scenes.new_ledger_fixture() var runtime: Variant = resumed[&"runtime"] _expect(bool(runtime.load_from_path(paths.get(name, "")).get(&"ok", false)) and int(resumed[&"clock"].absolute_minute) == int(point[0]), "F8 same process %s: the save loads at its minute" % name) _expect(runtime.staff_service.has_staff(Scenes.DISMISSED) == (name == "1500-before"), "F8 same process %s: the employee is there exactly before the command" % name) _scenes.play_ledger(resumed, int(point[0]), Scenes.LAST_MINUTE, Callable()) _expect(_scenes.economy_bytes(runtime) == straight_economy and _scenes.state_value(runtime, resumed[&"clock"]) == straight_state, "F8 same process %s: resuming gives exactly the uninterrupted ledger, employees and Tasks" % name) _expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0 and _payroll_payments(runtime, 2) == {Scenes.DISMISSED: 120, Scenes.KEPT: 180}, "F8 same process %s: the severance and the held 120 are paid once" % name) _scenes.free_fixture(resumed) SaveService.delete_save(paths.get(name, "")) _scenes.free_fixture(straight) _scenes.free_fixture(saving) ## SPEC 4.1 d: a rescue cleanup that cannot complete rolls the whole dismissal back ## (5.c). Fixture: the medic's pickup names a Reservation that does not exist, so the ## existing cancellation refuses it. func _test_rescue_cleanup_failure_rolls_back() -> void: var fixture := _scenes.new_rescue_fixture() var runtime: Variant = fixture[&"runtime"] _expect(_scenes.advance_to_rescue_phase(fixture, "rescue_pickup/MOVING/-/single"), "the medic walks to the patient") var task: StaffTask = runtime.staff_service.get_task(runtime.staff_service.get_staff(&"medic").current_task_id) task.reservation_ids.append(&"p16.fixture.missing_reservation") _expect_executor_refusal(fixture, &"patient", &"RESCUE_CLEANUP_FAILED", "rescue cleanup failure") _expect(runtime.staff_service.has_staff(&"patient") and _scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 0, "the patient stays and no severance is posted") _scenes.free_fixture(fixture) ## SPEC 4.1 d, the guarded branches the other scenes do not fail. Each changes nothing ## but the command record (5.c). ## (1) The removal contract cannot release the held Task's Reservation although the ## pre-check passes. Disclosed fixture: the release operation id is already bound ## to another inventory reservation, so InventoryService refuses the release. ## (2) The seat of a boarded rescue pair cannot be released. Disclosed fixture: the ## medic's Path request was completed beforehand through the public PathService ## call, a state the save check refuses. ## (3) The severance cannot be posted after the dismissed carrier's seat was released, ## so the Path request, the cabin seat and the pairing are restored with the rest. func _test_rollback_branches() -> void: var fixture := _scenes.new_fixture(16_150) var runtime: Variant = fixture[&"runtime"] var minute: int = fixture[&"clock"].absolute_minute _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker", &"role_id": &"worker", &"options": {&"state": StaffRuntime.State.AVAILABLE, &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"role_tags": [&"worker"], &"permitted_task_tags": [&"work"], &"daily_wage": 100}}).is_success() and _scenes.command(fixture, &"add_inventory_stack", {&"owner_inventory_id": &"staff.supplies", &"item_id": &"tool/snapshot_kit", &"quantity": 2}).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, two tools and a Task that needs one") fixture[&"clock"].advance_one_minute() minute = fixture[&"clock"].absolute_minute var task: StaffTask = runtime.staff_service.get_task(&"tool_task") _expect(task.state == StaffTask.State.WORKING and task.reservation_ids.size() == 1, "the worker holds the Task and its Reservation") var held: StringName = task.reservation_ids[0] if task.reservation_ids.size() == 1 else &"" var bound: Dictionary = runtime.inventory_service.reserve_item(&"staff.supplies", &"tool/snapshot_kit", 1, &"p16.fixture", &"staff_task", minute, minute + 60, StringName("staff.task.release.%s.%s" % [task.task_id, held])) _expect(bool(bound.get(&"ok", false)) and bool(runtime.get_staff_dismissal_preview(&"tool_worker")[&"can_dismiss"]), "disclosed fixture: the release operation id is bound to another reservation; the pre-check still passes") var refused := _expect_executor_refusal(fixture, &"tool_worker", &"RESERVATION_RELEASE_FAILED", "(1) the removal contract's release fails") _expect(refused.message == "Staff removal could not release Task Reservations." and runtime.staff_service.has_staff(&"tool_worker") and runtime.inventory_service.get_reservation(held).is_active() and runtime.staff_service.get_task(&"tool_task").assigned_staff_id == &"tool_worker", "(1) the existing contract's text; the employee, the Task and its Reservation are as before") _scenes.free_fixture(fixture) for scene: Array in [["(2) the seat release fails", &"RESCUE_SEAT_RELEASE_FAILED"], ["(3) the severance fails after the seat release", &"SEVERANCE_POST_FAILED"]]: var rescue := _scenes.new_rescue_fixture() var rescue_runtime: Variant = rescue[&"runtime"] _expect(_scenes.advance_to_rescue_phase(rescue, "rescue_transport/MOVING/BOARDED/pair"), "%s: the pair rides the elevator" % scene[0]) var trip: Dictionary = rescue_runtime.elevators.get_actor_trip(&"staff:medic") if scene[1] == &"RESCUE_SEAT_RELEASE_FAILED": _expect(rescue_runtime.path_service.complete_elevator_request(StringName(trip.get(&"connector_id", &"")), StringName(trip.get(&"request_id", &""))), "%s: disclosed fixture: the medic's Path request is completed beforehand" % scene[0]) else: var next_id := int(rescue_runtime.create_snapshot()[&"systems"][&"runtime"][&"next_command_id"]) _expect(rescue_runtime.economy_service.transfer(Scenes.PAYER, Scenes.FRONT_SALES, 1, rescue[&"clock"].absolute_minute, &"TRANSFER", &"p16.fixture", StringName("staff.severance.%d" % next_id)).is_success(), "%s: disclosed fixture: the dismissal's posting key is already used" % scene[0]) _expect_executor_refusal(rescue, &"medic", scene[1], scene[0]) var medic_trip: Dictionary = rescue_runtime.elevators.get_actor_trip(&"staff:medic") var patient_trip: Dictionary = rescue_runtime.elevators.get_actor_trip(&"staff:patient") _expect(rescue_runtime.staff_service.has_staff(&"medic") and medic_trip == trip and StringName(medic_trip.get(&"companion_id", &"")) == &"staff:patient" and StringName(patient_trip.get(&"companion_id", &"")) == &"staff:medic", "%s: the medic is still aboard with the patient" % scene[0]) if scene[1] == &"SEVERANCE_POST_FAILED": var saved: Dictionary = rescue_runtime.create_snapshot() _expect(rescue_runtime.restore_snapshot(saved) and rescue_runtime.create_snapshot() == saved, "%s: the restored game saves and restores exactly" % scene[0]) _scenes.free_fixture(rescue) ## F9: the game's own Runtime and Main, driven by viewport input. Main's key and every ## button of the dismissal flow (the hiring buttons, 雇用済み, the roster rows, 解雇する…, ## やめる, 解雇を確定) are pressed through root.push_input in the viewport's own ## coordinates after the control is scrolled into view (_click, the form of ## tests/ui/test_business_management.gd _click_principal_button). The panel shows the ## severance, the hidden-fund top-up, the reopened Task, the rescue the command ## interrupts and the held wages before the press, dismisses, shows the result and a ## refusal that arrives after the press, and for every refusal row (R1, R2, R3, R5, R7, ## R8, R9) keeps the button disabled with the reason and sends nothing when it is ## clicked (5.a, whole save bytes). This is viewport input, not OS input; the Windows ## device, IME, OS scaling and the same-candidate device check are later (SPEC 10). 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 fixture := {&"runtime": runtime, &"clock": clock, &"state": state} await _press_key(KEY_H) _expect((main.get("staff_candidate_panel") as Control).visible, "the H key opens the hiring panel") 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: await _click(hire) _expect(runtime.staff_service.has_staff(candidate), "a click hires %s" % candidate) _expect(_scenes.command(fixture, &"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(_scenes.command(fixture, &"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) await _click(main.get("staff_management_toggle_button") as Button) var panel: StaffManagementPanel = main.get("staff_management_panel") _expect(panel.visible and panel.roster_buttons.size() == 4, "a click on 雇用済み shows the four employees") await _click(panel.roster_buttons[&"staff_akane"]) _expect(panel.selected_staff_id == &"staff_akane" and not panel.dismiss_button.disabled and panel.dismiss_button.tooltip_text.contains("退職金 G360"), "a click on her row offers the dismissal with severance G360") var untouched := var_to_bytes(runtime.create_snapshot()) await _click(panel.dismiss_button) _expect(panel.dismiss_confirm_box.visible and panel.dismiss_confirm_label.text.contains("退職金 G360(日給の3日分)") and var_to_bytes(runtime.create_snapshot()) == untouched, "a click on 解雇する… shows the severance and sends nothing (5.a, bytes)") await _click(panel.dismiss_cancel_button) _expect(not panel.dismiss_confirm_box.visible and var_to_bytes(runtime.create_snapshot()) == untouched, "a click on やめる closes the confirmation without a command (bytes)") await _click(panel.dismiss_button) await _click(panel.dismiss_confirm_button) _expect(not runtime.staff_service.has_staff(&"staff_akane") and not panel.roster_buttons.has(&"staff_akane") and panel.selected_staff_id != &"staff_akane", "a click on 解雇を確定 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") await _click(panel.roster_buttons[&"staff_eri"]) await _click(panel.dismiss_button) _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") await _click(panel.dismiss_confirm_button, true) _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) _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("解雇しました") and panel.result_label.text.contains("持っていた作業は開き直しました"), "the queued dismissal completes and reopens her Task") # The rescue the command interrupts (SPEC 6.2.1), on one floor: a collapsed medic (the # patient) and a medic who carries her to the infirmary on foot. var infirmary := _scenes.command(fixture, &"build_facility", {&"definition_id": &"infirmary", &"origin": Vector3i(8, 0, 1)}) _expect(infirmary.is_success(), "build the infirmary (%s %s)" % [infirmary.message, infirmary.details]) clock.advance_minutes(runtime.definition_registry.get_facility(&"infirmary").build_minutes) for id: StringName in [&"patient", &"medic"]: var options := { &"current_cell": Vector3i(-2, 0, 1) if id == &"patient" else Vector3i(-10, 0, 1), &"health": 0 if id == &"patient" else 100, &"state": StaffRuntime.State.AVAILABLE, &"role_tags": [&"medic"], &"permitted_task_tags": [&"rescue"], &"daily_wage": 100, } _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": id, &"role_id": &"medic", &"options": options}).is_success(), "register rescue employee %s" % id) _expect(_scenes.advance_to_rescue_phase(fixture, "rescue_transport/MOVING/-/single"), "the medic carries the patient on foot (%s)" % _scenes.rescue_phase(runtime)) panel.refresh() await _click(panel.roster_buttons[&"patient"]) var carrying := var_to_bytes(runtime.create_snapshot()) await _click(panel.dismiss_button) _expect(panel.dismiss_confirm_label.text.contains("この従業員への救護は取り消し、救護係を外します。") and var_to_bytes(runtime.create_snapshot()) == carrying, "the patient's confirmation says her rescue is cancelled: %s" % panel.dismiss_confirm_label.text) await _click(panel.dismiss_cancel_button) await _click(panel.roster_buttons[&"medic"]) await _click(panel.dismiss_button) _expect(panel.dismiss_confirm_label.text.contains("運んでいる救護の搬送は取り消し、患者の救護をやり直します。") and not panel.dismiss_confirm_label.text.contains("開き直して"), "the carrier's confirmation says the transport is cancelled, not reopened: %s" % panel.dismiss_confirm_label.text) await _click(panel.dismiss_confirm_button) _expect(not runtime.staff_service.has_staff(&"medic") and panel.result_label.text.contains("関わっていた救護の作業は取り消しました。") and not panel.result_label.text.contains("開き直しました"), "the carrier's result names the cancelled rescue: %s" % panel.result_label.text) await _click(panel.roster_buttons[&"patient"]) await _click(panel.dismiss_button) await _click(panel.dismiss_confirm_button) _expect(not runtime.staff_service.has_staff(&"patient"), "the patient is dismissed too, so the later rows are as before") for row: Array in [[&"heroine_anri", "ユニーク:雇用・維持費無料 / 解雇・売却不可", "R1"], [&"adult_contract", "ギルドで雇った一般従業員だけを解雇できます。", "R7"]]: await _expect_refused_row(panel, runtime, row[0], row[1], row[2]) # R9: the defender of an unfinished raid. var objective_build := _scenes.command(fixture, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-8, 0, 0)}) _expect(objective_build.is_success(), "build the raid objective (%s %s)" % [objective_build.message, objective_build.details]) clock.advance_minutes(runtime.definition_registry.get_facility(&"reception").build_minutes) var objective: StringName = &"" for facility: FacilityInstance in runtime.facility_service.get_instances(): if facility.definition_id == &"reception": objective = facility.instance_id _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_klaus"}).is_success() and runtime.submit_command(&"create_defense_raid", {&"raid_id": &"ui.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").is_success() and runtime.submit_command(&"register_defense_combatant", {&"raid_id": &"ui.raid", &"actor_id": &"staff_klaus", &"side": &"DEFENDER", &"combat": 90, &"max_health": 100, &"options": {}}, &"defense_controller").is_success(), "the guard defends an unfinished raid") await _expect_refused_row(panel, runtime, &"staff_klaus", "襲撃の防衛に参加中は解雇できません。", "R9") # R2: the porter carrying supply logistics. for spec: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(4, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]: var built := _scenes.command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]}) _expect(built.is_success(), "build %s (%s %s)" % [spec[0], built.message, built.details]) 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(not source.is_empty() and not destination.is_empty() and _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(), "the porter works at the warehouse") if source.is_empty() or destination.is_empty(): var early: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main) _expect(bool(early.get(&"passed", false)), "ordinary Main release with audio cleanup") return 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_ui", &"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(), "a supply delivery") for minute: int in 40: if runtime.staff_service.is_externally_driven_task(runtime.staff_service.get_staff(&"staff_gina").current_task_id): break clock.advance_minutes(1) await _expect_refused_row(panel, runtime, &"staff_gina", "物流の受け取り・配達・返送の途中です。", "R2") # R3: a Task Reservation that cannot be released (disclosed fixture: the tool is consumed). _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_tool_worker", &"role_id": &"worker", &"options": {&"state": StaffRuntime.State.AVAILABLE, &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"role_tags": [&"worker"], &"permitted_task_tags": [&"work"], &"daily_wage": 100}}).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": &"ui_tool_task", &"task_type": &"inspect", &"priority": 95, &"options": { &"target_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"required_role_tags": [&"worker"], &"required_items": {&"tool/snapshot_kit": 1}, &"task_tags": [&"work"], &"estimated_minutes": 600}}).is_success(), "a worker and a Task with a tool") clock.advance_minutes(1) var tool_task: StaffTask = runtime.staff_service.get_task(&"ui_tool_task") var before_consume: Dictionary = runtime.create_snapshot() _expect(tool_task.assigned_staff_id == &"ui_tool_worker" and tool_task.reservation_ids.size() == 1 and bool(runtime.inventory_service.consume_reservation(tool_task.reservation_ids[0], &"p16.ui.consume", clock.absolute_minute).get(&"ok", false)), "fixture consumes the reserved tool") await _expect_refused_row(panel, runtime, &"ui_tool_worker", "作業の予約を解けないため解雇できません。", "R3") _expect(runtime.restore_snapshot(before_consume), "the fixture's consumption is undone after the check") panel.refresh() # The hidden fund covers the part of the severance the front fund lacks (A14). # Disclosed balance fixtures: the front fund holds G100 and a hidden-fund sale adds # G200, so the funds return to the earlier balances after the dismissal. for account: StringName in [Scenes.FRONT_SALES, Scenes.BACK_SALES, Scenes.PAYER]: runtime.economy_service.register_account(account) _expect(runtime.economy_service.initialize_account(Scenes.PAYER, 100000, clock.absolute_minute, &"p16.ui.payer").is_success(), "disclosed fixture: a payer for the balance fixtures") _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_top_up", &"role_id": &"cleaner", &"options": {&"daily_wage": 100}}).is_success(), "an employee whose severance needs the hidden fund") var front_before: int = runtime.economy_service.get_balance(FRONT) var back_before: int = runtime.economy_service.get_balance(BACK) _expect(front_before > 0 and _scenes.drain_front(fixture, "ui.top_up") and _scenes.sale(fixture, FRONT, 100, "ui.top_up.front") and _scenes.sale(fixture, BACK, 200, "ui.top_up.back"), "disclosed fixture: front G100, hidden G%d" % (back_before + 200)) panel.refresh() await _click(panel.roster_buttons[&"ui_top_up"]) await _click(panel.dismiss_button) _expect(panel.dismiss_confirm_label.text.contains("退職金 G300(日給の3日分)を表金から払います。不足 G200 は裏金から補います。"), "the confirmation shows the hidden-fund top-up: %s" % panel.dismiss_confirm_label.text) await _click(panel.dismiss_confirm_button) _expect(not runtime.staff_service.has_staff(&"ui_top_up") and panel.result_label.text.contains("退職金 G300 を払いました(不足 G200 は裏金から補填)"), "the result shows the top-up: %s" % panel.result_label.text) _expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == back_before and _scenes.sale(fixture, FRONT, front_before, "ui.top_up.restore") and runtime.economy_service.get_balance(FRONT) == front_before, "the funds are back at G%d and G%d" % [front_before, back_before]) # A refusal that arrives after the press: the funds are spent before the queued command runs. _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_late", &"role_id": &"cleaner", &"options": {&"daily_wage": 100}}).is_success(), "an employee to dismiss") panel.refresh() await _click(panel.roster_buttons[&"ui_late"]) await _click(panel.dismiss_button) await _click(panel.dismiss_confirm_button, true) _expect(runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("受理しました"), "the dismissal is accepted for the next minute") _expect(_scenes.drain_front(fixture, "ui.late") and runtime.economy_service.get_balance(BACK) < 300, "disclosed fixture: the funds are spent before the command runs") clock.advance_minutes(1) _expect(runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("退職金 G300 を払えません"), "the result shows the refusal after the press: %s" % panel.result_label.text) await _expect_refused_row(panel, runtime, &"ui_late", "退職金 G300 を払えません", "R8") # The held unpaid wages: the first 00:00 close finds no funds, so the day's wage stays unpaid (A09). clock.advance_minutes(1441 - int(clock.absolute_minute)) _expect(runtime.staff_service.get_staff(&"ui_late").unpaid_wages == 100, "the first close leaves her daily wage 100 unpaid") _expect(_scenes.sale(fixture, FRONT, 1000, "ui.sale"), "disclosed fixture: a sale refills the front fund") await _show_staff_panel(main, panel) panel.refresh() await _click(panel.roster_buttons[&"ui_late"]) await _click(panel.dismiss_button) _expect(panel.dismiss_confirm_label.text.contains("未払い G100 は退職金と別に次の0時に精算します"), "the confirmation shows the held wages: %s" % panel.dismiss_confirm_label.text) await _click(panel.dismiss_confirm_button) _expect(not runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("未払い G100 は次の0時に精算します"), "the result shows the held wages: %s" % panel.result_label.text) # R5: after the game ends (three actual operating losses with no funds). if not bool(runtime.get(&"_operations_enabled")): _expect(bool(runtime.enable_production_operations().get(&"ok", false)), "production Operations") for minute: int in 6000: if runtime.operations_coordinator.is_terminal(): break if posmod(int(clock.absolute_minute), 1440) == 1439: _scenes.drain_front(fixture, "ui.bankrupt.%d" % clock.absolute_minute) clock.advance_minutes(1) _expect(runtime.operations_coordinator.is_terminal(), "the game ends (BANKRUPT) after three actual operating losses") await _show_staff_panel(main, panel) await _expect_refused_row(panel, runtime, &"ui_tool_worker", "運営終了後は解雇できません。", "R5") var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main) _expect(bool(cleanup.get(&"passed", false)), "ordinary Main release with audio cleanup") ## A refusal row: a click selects the row, the button is disabled with the reason, and a ## click on the disabled button sends nothing (5.a, whole save bytes). func _expect_refused_row(panel: StaffManagementPanel, runtime: Variant, staff_id: StringName, text: String, label: String) -> void: panel.refresh() _expect(panel.roster_buttons.has(staff_id), "%s: the row is listed" % label) if not panel.roster_buttons.has(staff_id): return await _click(panel.roster_buttons[staff_id]) var before := var_to_bytes(runtime.create_snapshot()) _expect(panel.selected_staff_id == staff_id and panel.dismiss_button.disabled and panel.staff_detail_label.text.contains(text) and panel.dismiss_button.tooltip_text.contains(text), "%s: the button is disabled and the reason is shown (%s)" % [label, panel.dismiss_button.tooltip_text]) await _click(panel.dismiss_button) _expect(var_to_bytes(runtime.create_snapshot()) == before and not panel.dismiss_confirm_box.visible and runtime.staff_service.has_staff(staff_id), "%s: a click on the disabled button sends nothing (5.a, whole save bytes)" % label) ## Viewport input (the _click_principal_button form of tests/ui/test_business_management.gd): ## the control is scrolled into view, the mouse moves to its centre and presses and ## releases there, in the viewport's own coordinates (Main is scaled from 1440x810). ## A roster row rebuilt by the panel in this frame scrolls only from the next frame ## (ScrollContainer.ensure_control_visible), and the roster's own scroll sits inside ## the side panel's scroll, so each is asked again after a frame. ## `running`: the clock runs only for the release, so a press while time runs sends ## the command for the next minute and no frame advances a minute. func _click(control: Control, running := false) -> void: for pass_index: int in 2: await process_frame var ancestor: Node = control.get_parent() while ancestor != null: if ancestor is ScrollContainer: ancestor.ensure_control_visible(control) ancestor = ancestor.get_parent() await process_frame var clock: Variant = root.get_node("GameClock") var point := control.get_global_rect().get_center() var motion := InputEventMouseMotion.new() motion.position = point motion.global_position = point root.push_input(motion, true) for pressed: bool in [true, false]: var event := InputEventMouseButton.new() event.button_index = MOUSE_BUTTON_LEFT event.pressed = pressed event.position = point event.global_position = point event.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0 if running and not pressed: clock.set_paused(false) root.push_input(event, true) if running and not pressed: clock.set_paused(true) await process_frame ## After a 00:00 close Main shows the ledger in the side panel (_on_snapshot_published) ## and hides the staff controls; the player returns with the 従業員 navigation button. func _show_staff_panel(main: Control, panel: StaffManagementPanel) -> void: _expect(not panel.is_visible_in_tree(), "after the close Main shows the ledger instead of the staff panel") await _click((main.get("navigation_buttons") as Dictionary)[&"staff"] as Button) _expect(panel.is_visible_in_tree(), "a click on 従業員 brings the hired-staff panel back") ## A key press and release through the viewport (Main's _unhandled_key_input). func _press_key(keycode: Key) -> void: for pressed: bool in [true, false]: var event := InputEventKey.new() event.keycode = keycode event.physical_keycode = keycode event.pressed = pressed root.push_input(event, true) await process_frame