826 lines
54 KiB
GDScript
826 lines
54 KiB
GDScript
extends SceneTree
|
||
const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd")
|
||
|
||
## P05-02: front (表金) and hidden (裏金) funds, the A09 payment rule, the A10 rank
|
||
## tax, the A11 loan interest, the three business day reconciliation and saves.
|
||
## The policy and reconciliation scenes use explicit ledger fixtures for sales,
|
||
## debt and drains, with the production Operations closes. The repair 2 scenes
|
||
## run the production paths themselves: built and staffed facilities whose guests
|
||
## complete their services, the automatic purchase, the repayment command and the
|
||
## legacy 00:00 upkeep; a fixture there only sets a balance (a loan, a drain), never
|
||
## the payment under test. The last check opens the ledger from the game's own Main
|
||
## (F7), so the runner gives this suite the ordinary Main audio cleanup proof
|
||
## (tools/regression/suite-policy.json).
|
||
|
||
const FRONT := &"PLAYER_CASH"
|
||
const BACK := &"BACK_CASH"
|
||
const LOAN := &"LOAN_LIABILITY:p05.fixture"
|
||
const FRONT_SALES := &"P05_FIXTURE_FRONT_SALES"
|
||
const BACK_SALES := &"P05_FIXTURE_BACK_SALES"
|
||
const PAYER := &"P05_FIXTURE_PAYER"
|
||
const SINK := &"P05_FIXTURE_SINK"
|
||
|
||
var _checks := 0
|
||
var _failures := 0
|
||
|
||
|
||
func _initialize() -> void:
|
||
call_deferred("_run")
|
||
|
||
|
||
func _run() -> void:
|
||
print("P05-02: front and hidden funds")
|
||
_test_policy_values()
|
||
_test_sale_fund_routing()
|
||
_test_three_business_days()
|
||
_test_resume_matches_uninterrupted()
|
||
_test_old_save_migration()
|
||
_test_repayment_top_up()
|
||
_test_purchase_top_up()
|
||
_test_upkeep_top_up()
|
||
_test_actual_service_funds()
|
||
_test_operation_top_ups_and_day_total()
|
||
_test_legacy_midnight_top_up()
|
||
_test_tax_arrears_next_day()
|
||
_test_ledger_repayment_uses_hidden_funds()
|
||
await _test_ledger_opened_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)
|
||
|
||
|
||
func _new_fixture(seed: int) -> 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()
|
||
_expect(bool(runtime.initialize(clock, state, seed).get(&"valid", false)), "fixture Runtime initializes")
|
||
clock.set_paused(true)
|
||
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||
_expect(bool(runtime.enable_production_operations().get(&"ok", false)), "fixture enables production Operations")
|
||
for account: StringName in [FRONT_SALES, BACK_SALES, PAYER, SINK]:
|
||
runtime.economy_service.register_account(account)
|
||
runtime.economy_service.register_account(LOAN, true)
|
||
# Guests' Gold enters the ledger only through explicit initialization.
|
||
_expect(runtime.economy_service.initialize_account(PAYER, 100000, clock.absolute_minute, &"p05.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()
|
||
|
||
|
||
# Same accelerated boundary traversal as tests/mvp/test_runtime_operations_acceptance.gd:
|
||
# the fixtures hold no Facilities, Staff or logistics, only ledger postings, which
|
||
# are made on 30-minute marks.
|
||
func _advance(fixture: Dictionary, amount: int) -> void:
|
||
var clock: Variant = fixture[&"clock"]
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var advanced := 0
|
||
while advanced < amount:
|
||
# Every Operations boundary is on a 30-minute mark; each step ends on one.
|
||
var step := mini(30 - int(clock.absolute_minute) % 30, amount - advanced)
|
||
var target: int = clock.absolute_minute + step
|
||
clock.absolute_minute = target
|
||
clock.call("_sync_calendar_fields")
|
||
clock.snapshot_version += step
|
||
runtime.call("_advance_weather", target)
|
||
runtime.call("_advance_facility_upkeep", target)
|
||
runtime.call("_advance_operations_time", target)
|
||
_align_clocks(runtime, target)
|
||
advanced += step
|
||
|
||
|
||
# Same as tests/mvp/test_runtime_operations_acceptance.gd: the fixture owns no
|
||
# actors, cars or rides, so only the empty aggregates' clocks follow the minute.
|
||
func _align_clocks(runtime: Variant, minute: int) -> void:
|
||
for service: Variant in [runtime.inventory_service, runtime.facility_service, runtime.kitchen_production,
|
||
runtime.guest_service, runtime.staff_service, runtime.authority_service, runtime.defense_service,
|
||
runtime.construction_service, runtime.excavation_service]:
|
||
service.current_minute = minute
|
||
var transport_snapshot: Dictionary = runtime.elevators.create_snapshot()
|
||
if not (transport_snapshot[&"shafts"] as Array).is_empty() or not (transport_snapshot[&"trips"] as Array).is_empty():
|
||
_expect(false, "accelerated fixture cannot skip live elevator movement")
|
||
return
|
||
transport_snapshot[&"current_minute"] = minute
|
||
if not runtime.elevators.restore_snapshot(transport_snapshot):
|
||
_expect(false, "empty transport clock aligns through validated snapshot restore")
|
||
|
||
|
||
func _advance_to(fixture: Dictionary, minute: int) -> void:
|
||
_advance(fixture, minute - int(fixture[&"clock"].absolute_minute))
|
||
|
||
|
||
func _sale(fixture: Dictionary, revenue_account: StringName, fund: StringName, amount: int, key: String) -> bool:
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var minute: int = fixture[&"clock"].absolute_minute
|
||
var entries: Array = [
|
||
LedgerEntry.new().configure(&"", minute, revenue_account, PAYER, amount, &"SERVICE_SALE", &"p05.fixture"),
|
||
LedgerEntry.new().configure(&"", minute, fund, revenue_account, amount, &"SERVICE_CASH_SWEEP", &"p05.fixture"),
|
||
]
|
||
return runtime.economy_service.post(entries, StringName("p05.sale." + key)).is_success()
|
||
|
||
|
||
func _move(fixture: Dictionary, from: StringName, to: StringName, amount: int, category: StringName, key: String) -> bool:
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
return runtime.economy_service.transfer(from, to, amount, fixture[&"clock"].absolute_minute, category,
|
||
&"p05.fixture", StringName("p05.move." + key)).is_success()
|
||
|
||
|
||
func _fund_delta(runtime: Variant, account: StringName, after_minute: int, through_minute: int) -> int:
|
||
var delta := 0
|
||
for entry: LedgerEntry in runtime.economy_service.get_entries():
|
||
if entry.absolute_minute <= after_minute or entry.absolute_minute > through_minute:
|
||
continue
|
||
if entry.debit_account == account:
|
||
delta += entry.amount
|
||
if entry.credit_account == account:
|
||
delta -= entry.amount
|
||
return delta
|
||
|
||
|
||
func _test_policy_values() -> void:
|
||
_expect(FinancePolicy.rank_tax(800) == 80 and FinancePolicy.rank_tax(109) == 10 and FinancePolicy.rank_tax(9) == 0, "A10 rank tax is 10% rounded down")
|
||
_expect(FinancePolicy.rank_tax(-5) == 0, "no tax on a negative day")
|
||
_expect(FinancePolicy.loan_interest(1000) == 10 and FinancePolicy.loan_interest(199) == 1 and FinancePolicy.loan_interest(99) == 0, "A11 interest is 1% per day rounded down")
|
||
|
||
|
||
func _test_sale_fund_routing() -> void:
|
||
var fixture := _new_fixture(5021)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var back_ids: Array[StringName] = []
|
||
var front_ids: Array[StringName] = []
|
||
# Expansion definitions are read from their files; they are not registered by default.
|
||
for path: String in ["res://data/facilities/adult_private_room.tres", "res://data/facilities/expansion/black_market.tres",
|
||
"res://data/facilities/expansion/vip_private_salon.tres", "res://data/facilities/cafeteria.tres"]:
|
||
var definition: Variant = load(path)
|
||
if definition == null:
|
||
continue
|
||
if FinancePolicy.is_back_fund_service(definition.tags):
|
||
back_ids.append(definition.id)
|
||
else:
|
||
front_ids.append(definition.id)
|
||
_expect(back_ids.has(&"adult_private_room") and back_ids.has(&"black_market") and back_ids.has(&"vip_private_salon"), "adult, trade and private salon services earn hidden funds")
|
||
_expect(front_ids.has(&"cafeteria"), "dining earns front funds")
|
||
_expect(runtime.economy_service.has_account(BACK) and not runtime.economy_service.account_allows_negative(BACK), "the hidden fund is its own non-negative account")
|
||
_expect(runtime.economy_service.get_balance(BACK) == 0 and runtime.economy_service.get_balance(FRONT) == 5000, "a new game starts with every Gold in front funds")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
func _test_three_business_days() -> void:
|
||
var fixture := _new_fixture(5022)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var state: Variant = fixture[&"state"]
|
||
var coordinator: Variant = runtime.operations_coordinator
|
||
# Day 1: borrow 1000, front sale 300, hidden sale 500.
|
||
_advance_to(fixture, 420)
|
||
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "borrow"), "fixture principal borrowed")
|
||
_expect(_sale(fixture, FRONT_SALES, FRONT, 300, "d1.front") and _sale(fixture, BACK_SALES, BACK, 500, "d1.back"), "day 1 sales")
|
||
_advance_to(fixture, 1440)
|
||
var day1: Dictionary = coordinator.get_day_summary(1)
|
||
_expect(not day1.is_empty(), "day 1 closes at 00:00")
|
||
var totals1: Dictionary = day1.get(&"category_totals", {})
|
||
_expect(int(totals1.get(&"revenue", -1)) == 800, "day 1 sales are front + hidden")
|
||
_expect(int(totals1.get(&"taxes_and_interest", -1)) == 90, "day 1 rank tax 80 and interest 10")
|
||
_expect(int(totals1.get(&"financing_principal_inflows", -1)) == 1000, "principal stays financing, outside sales")
|
||
var funds1: Dictionary = day1.get(&"settlement", {}).get(&"funds", {})
|
||
_expect(int(funds1.get(&"revenue_front", -1)) == 300 and int(funds1.get(&"revenue_back", -1)) == 500, "day 1 fund report splits sales")
|
||
_expect(int(funds1.get(&"rank_tax", -1)) == 80 and int(funds1.get(&"rank_tax_paid", -1)) == 80, "day 1 rank tax paid in full")
|
||
_expect(int(funds1.get(&"loan_interest", -1)) == 10 and int(funds1.get(&"loan_interest_paid", -1)) == 10, "day 1 interest paid in full")
|
||
_expect(int(funds1.get(&"back_top_up", -1)) == 0, "front funds alone paid day 1")
|
||
_expect(runtime.economy_service.get_balance(FRONT) == 6210 and runtime.economy_service.get_balance(BACK) == 500, "day 1 balances")
|
||
_expect(state.get_resource(&"gold") == runtime.economy_service.get_balance(FRONT), "Gold mirrors front funds")
|
||
# The starting Gold is posted at 06:00 (minute 360); day 1 changes follow it.
|
||
_expect(_fund_delta(runtime, FRONT, 360, 1440) == 1210 and _fund_delta(runtime, BACK, 360, 1440) == 500, "day 1 fund changes equal the ledger")
|
||
# Day 2: front funds fall to 5, so 5 of the 10 interest comes from hidden funds.
|
||
_advance_to(fixture, 1440 + 600)
|
||
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", 6205, &"OPERATING_EXPENSE", "d2.cost"), "day 2 purchase-like cost")
|
||
_advance_to(fixture, 2880)
|
||
var funds2: Dictionary = coordinator.get_day_summary(2).get(&"settlement", {}).get(&"funds", {})
|
||
_expect(int(funds2.get(&"back_top_up", -1)) == 5, "A09: hidden funds cover the 5 front funds cannot pay")
|
||
_expect(int(funds2.get(&"loan_interest_paid", -1)) == 10 and int(funds2.get(&"rank_tax", -1)) == 0, "day 2 interest paid, no sales no tax")
|
||
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 495, "day 2 balances")
|
||
_expect(int(coordinator.get_day_summary(2)[&"category_totals"][&"revenue"]) == 0, "the top-up is not sales")
|
||
_expect(_fund_delta(runtime, FRONT, 1440, 2880) == -6210 and _fund_delta(runtime, BACK, 1440, 2880) == -5, "day 2 fund changes equal the ledger")
|
||
# Day 3: hidden funds are gone too, so the interest stays unpaid.
|
||
_advance_to(fixture, 2880 + 600)
|
||
_expect(_move(fixture, BACK, SINK, 495, &"TRANSFER", "d3.drain"), "day 3 hidden funds drained")
|
||
_advance_to(fixture, 4320)
|
||
var day3: Dictionary = coordinator.get_day_summary(3)
|
||
var funds3: Dictionary = day3.get(&"settlement", {}).get(&"funds", {})
|
||
_expect(int(funds3.get(&"loan_interest", -1)) == 10 and int(funds3.get(&"loan_interest_paid", -1)) == 0 and int(funds3.get(&"loan_interest_arrears", -1)) == 10, "A09: unpaid interest carries over")
|
||
_expect(int(day3[&"settlement"][&"arrears_total"]) == 10, "day 3 arrears include the interest")
|
||
_expect(int(runtime.get_operations_report().get(&"arrears_total", -1)) == 10, "Operations report sees the interest arrears")
|
||
_expect(int(day3[&"category_totals"][&"taxes_and_interest"]) == 10, "the unpaid interest is still the day's expense")
|
||
# Day 4: a front sale pays the older interest first.
|
||
_advance_to(fixture, 4320 + 600)
|
||
_expect(_sale(fixture, FRONT_SALES, FRONT, 100, "d4.front"), "day 4 sale")
|
||
_advance_to(fixture, 5760)
|
||
var funds4: Dictionary = coordinator.get_day_summary(4).get(&"settlement", {}).get(&"funds", {})
|
||
_expect(int(funds4.get(&"rank_tax", -1)) == 10 and int(funds4.get(&"rank_tax_paid", -1)) == 10, "day 4 tax paid")
|
||
_expect(int(funds4.get(&"loan_interest_paid", -1)) == 20 and int(funds4.get(&"loan_interest_arrears", -1)) == 0, "day 4 pays the older interest and the new one")
|
||
_expect(runtime.economy_service.get_balance(FRONT) == 70, "day 4 front balance")
|
||
_expect(runtime.economy_service.validate_ledger()[&"ok"], "the ledger reconciles after four closes")
|
||
_check_ledger_panel(runtime, fixture[&"clock"])
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "four closed days restore exactly")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
func _scripted_days(fixture: Dictionary, restore_at: Array[int], through_files := false) -> Dictionary:
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
# Postings and save/restore points in time order; the last minute closes day 3.
|
||
var steps: Array = [
|
||
[420, func() -> void: _move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "borrow")],
|
||
[450, func() -> void: _sale(fixture, BACK_SALES, BACK, 700, "d1.back")],
|
||
[1440, Callable()],
|
||
[2040, func() -> void: _move(fixture, FRONT, &"OPERATING_EXPENSE", 5900, &"OPERATING_EXPENSE", "d2.cost")],
|
||
[2400, Callable()],
|
||
[2880, Callable()],
|
||
[3510, func() -> void: _sale(fixture, FRONT_SALES, FRONT, 50, "d3.front")],
|
||
[4320, Callable()],
|
||
]
|
||
for step: Array in steps:
|
||
var minute := int(step[0])
|
||
_advance_to(fixture, minute)
|
||
if (step[1] as Callable).is_valid():
|
||
(step[1] as Callable).call()
|
||
if minute in restore_at and through_files:
|
||
# The ordinary save file path: write the save, then load it back.
|
||
var path := "user://p05_resume_%d.save" % minute
|
||
_expect(bool(runtime.save_to_path(path).get(&"ok", false)), "save file at minute %d" % minute)
|
||
_expect(bool(runtime.load_from_path(path).get(&"ok", false)), "load the save file at minute %d" % minute)
|
||
elif minute in restore_at:
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved), "restore at minute %d" % minute)
|
||
return runtime.create_snapshot()
|
||
|
||
|
||
func _test_resume_matches_uninterrupted() -> void:
|
||
var straight := _new_fixture(5023)
|
||
var resumed := _new_fixture(5023)
|
||
var a := _scripted_days(straight, [])
|
||
var b := _scripted_days(resumed, [1440, 2400, 2880])
|
||
_expect(var_to_bytes(a) == var_to_bytes(b), "saving mid-day and at 00:00 changes nothing: no double tax, interest or top-up")
|
||
var runtime: Variant = resumed[&"runtime"]
|
||
var keys := 0
|
||
for entry: LedgerEntry in runtime.economy_service.get_entries():
|
||
if entry.category in [FinancePolicy.RANK_TAX_CATEGORY, FinancePolicy.LOAN_INTEREST_CATEGORY]:
|
||
keys += 1
|
||
# Day 1: tax 70 and interest 10; day 2: interest only (no sales); day 3: tax 5 and interest 10.
|
||
_expect(keys == 5, "each tax and interest accrual is posted once (no duplicates)")
|
||
# Through save files the save and load events are new, so compare the funds side only.
|
||
var through_files := _new_fixture(5023)
|
||
var c := _scripted_days(through_files, [1440, 2400, 2880], true)
|
||
_expect(var_to_bytes(c[&"systems"][&"runtime"][&"economy"]) == var_to_bytes(a[&"systems"][&"runtime"][&"economy"]), "saving to files and loading them back leaves the ledger exactly as without saving")
|
||
var straight_days: Array = straight[&"runtime"].operations_coordinator.get_day_summaries()
|
||
var file_days: Array = through_files[&"runtime"].operations_coordinator.get_day_summaries()
|
||
# Operations history is persisted in a transport-safe form (StringName ids become
|
||
# String values), so compare as the Runtime's own restore crosslink does.
|
||
var runtime_a: Variant = straight[&"runtime"]
|
||
_expect(runtime_a.call("_strict_variants_equal", runtime_a.call("_operations_snapshot_comparison_value", straight_days),
|
||
runtime_a.call("_operations_snapshot_comparison_value", file_days)), "the daily closes, fund reports and arrears are the same through save files")
|
||
_free_fixture(straight)
|
||
_free_fixture(resumed)
|
||
_free_fixture(through_files)
|
||
|
||
|
||
func _test_old_save_migration() -> void:
|
||
var fixture := _new_fixture(5024)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
_advance_to(fixture, 420)
|
||
var current: Dictionary = runtime.create_snapshot()
|
||
var old := current.duplicate(true)
|
||
var economy: Dictionary = old[&"systems"][&"runtime"][&"economy"]
|
||
var kept: Array = []
|
||
for account: Dictionary in economy[&"accounts"]:
|
||
if String(account[&"account_id"]) not in ["BACK_CASH", "RANK_TAX_EXPENSE", "RANK_TAX_PAYABLE", "LOAN_INTEREST_EXPENSE", "LOAN_INTEREST_PAYABLE"]:
|
||
kept.append(account)
|
||
economy[&"accounts"] = kept
|
||
_expect(runtime.restore_snapshot(old), "A12: a save from before the split loads")
|
||
_expect(runtime.economy_service.get_balance(FRONT) == 5000 and runtime.economy_service.get_balance(BACK) == 0, "A12: every Gold is front funds, hidden funds start at 0")
|
||
_expect(var_to_bytes(runtime.create_snapshot()) == var_to_bytes(current), "A12: the migrated save equals a current save of the same game")
|
||
var partial := old.duplicate(true)
|
||
var partial_accounts: Array = partial[&"systems"][&"runtime"][&"economy"][&"accounts"]
|
||
partial_accounts.append({&"account_id": "RANK_TAX_PAYABLE", &"allow_negative": true, &"system": false})
|
||
var before: Dictionary = runtime.create_snapshot()
|
||
_expect(not runtime.restore_snapshot(partial) and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "a partial set of fund accounts is rejected and changes nothing")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
func _test_repayment_top_up() -> void:
|
||
var fixture := _new_fixture(5025)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
_advance_to(fixture, 420)
|
||
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "borrow"), "fixture principal")
|
||
_expect(_sale(fixture, BACK_SALES, BACK, 300, "back") and _move(fixture, FRONT, &"OPERATING_EXPENSE", 5980, &"OPERATING_EXPENSE", "cost"), "front 20, hidden 300")
|
||
var receipts: Array = []
|
||
runtime.economy_service.transaction_posted.connect(func(id: StringName, _entries: Array) -> void: receipts.append(id))
|
||
var before: Dictionary = runtime.create_snapshot()
|
||
_expect(not runtime.repay_loan_principal(&"p05.fixture", 400, &"p05.repay.too_much").is_success() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "front + hidden short: repayment rejected, nothing changes")
|
||
var paid: CommandResult = runtime.repay_loan_principal(&"p05.fixture", 200, &"p05.repay")
|
||
_expect(paid.is_success() and int(paid.details.get(&"outstanding_principal", -1)) == 800, "repayment uses hidden funds for the rest")
|
||
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 120, "front 20 first, then 180 from hidden funds")
|
||
_expect(receipts.size() == 1, "payment and top-up are one transaction")
|
||
var entries: Array = runtime.economy_service.get_transaction_entries(StringName(paid.details[&"transaction_id"]))
|
||
_expect(entries.size() == 2 and entries[0].category == FinancePolicy.TOP_UP_CATEGORY and entries[1].category == &"LOAN_PRINCIPAL_REPAID", "top-up then principal")
|
||
_expect(fixture[&"state"].get_resource(&"gold") == 0, "Gold mirrors front funds after repayment")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
func _test_purchase_top_up() -> void:
|
||
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()
|
||
_expect(bool(runtime.initialize(clock, state, 5026).get(&"valid", false)), "purchase fixture Runtime initializes")
|
||
clock.set_paused(true)
|
||
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||
for row: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||
_expect(runtime.submit_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "build %s" % row[0])
|
||
clock.advance_minutes(180)
|
||
var warehouse := &""
|
||
for facility: FacilityInstance in runtime.facility_service.get_instances():
|
||
if facility.definition_id == &"warehouse":
|
||
warehouse = facility.instance_id
|
||
_expect(not warehouse.is_empty(), "warehouse completes")
|
||
var fixture := {&"clock": clock, &"state": state, &"runtime": runtime}
|
||
for account: StringName in [BACK_SALES, PAYER]:
|
||
runtime.economy_service.register_account(account)
|
||
_expect(runtime.economy_service.initialize_account(PAYER, 1000, clock.absolute_minute, &"p05.purchase.payer").is_success(), "fixture payer")
|
||
var front: int = runtime.economy_service.get_balance(FRONT)
|
||
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", front - 2, &"OPERATING_EXPENSE", "purchase.drain") and _sale(fixture, BACK_SALES, BACK, 100, "purchase.back"), "front 2, hidden 100")
|
||
var preview: Dictionary = runtime.preview_supply_purchase(warehouse, SupplyCatalog.RAW_FOOD, 5)
|
||
_expect(bool(preview.get(&"ok", false)) and int(preview[&"total_cost"]) == 15 and int(preview[&"cash_after"]) == 87, "A09: the quote counts front + hidden funds")
|
||
var paid: CommandResult = runtime.submit_command(&"purchase_supplies", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 5})
|
||
_expect(paid.is_success(), "the purchase uses hidden funds for what front funds cannot pay")
|
||
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 87, "front 2 first, then 13 from hidden funds")
|
||
var purchase_entries: Array = runtime.economy_service.get_transaction_entries(StringName(paid.details.get(&"transaction_id", &"")))
|
||
_expect(purchase_entries.size() == 1 and purchase_entries[0].credit_account == FRONT and purchase_entries[0].amount == 15, "the purchase receipt keeps its single front-fund entry")
|
||
var top_ups := 0
|
||
for entry: LedgerEntry in runtime.economy_service.get_entries():
|
||
if entry.category == FinancePolicy.TOP_UP_CATEGORY:
|
||
top_ups += entry.amount
|
||
_expect(top_ups == 13, "one 13 Gold top-up from hidden funds")
|
||
_expect(state.get_resource(&"gold") == 0, "Gold mirrors front funds after the purchase")
|
||
# A refused command is still recorded as processed; the funds and stock must not move.
|
||
var economy_before: Dictionary = runtime.economy_service.create_snapshot()
|
||
var inventory_before: Dictionary = runtime.inventory_service.create_snapshot()
|
||
var refused: CommandResult = runtime.submit_command(&"purchase_supplies", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40})
|
||
_expect(not refused.is_success() and StringName(refused.details.get(&"reason", &"")) == &"INSUFFICIENT_FUNDS", "front + hidden short: purchase refused for funds")
|
||
_expect(var_to_bytes(economy_before) == var_to_bytes(runtime.economy_service.create_snapshot()) \
|
||
and var_to_bytes(inventory_before) == var_to_bytes(runtime.inventory_service.create_snapshot()), "the refused purchase moves no Gold and no stock")
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "purchase with a top-up restores exactly")
|
||
runtime.free()
|
||
clock.free()
|
||
state.free()
|
||
|
||
|
||
func _test_upkeep_top_up() -> void:
|
||
var fixture := _new_fixture(5027)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var clock: Variant = fixture[&"clock"]
|
||
_expect(runtime.submit_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}).is_success(), "build a warehouse")
|
||
clock.advance_minutes(180)
|
||
_advance_to(fixture, 600)
|
||
var front: int = runtime.economy_service.get_balance(FRONT)
|
||
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", front, &"OPERATING_EXPENSE", "upkeep.drain") and _sale(fixture, BACK_SALES, BACK, 1000, "upkeep.back"), "front 0, hidden 1000")
|
||
_advance_to(fixture, 1440)
|
||
var summary: Dictionary = runtime.operations_coordinator.get_day_summary(1)
|
||
var maintenance := int(summary.get(&"category_totals", {}).get(&"maintenance", -1))
|
||
var funds: Dictionary = summary.get(&"settlement", {}).get(&"funds", {})
|
||
_expect(maintenance > 0, "the warehouse owes upkeep for day 1")
|
||
_expect(int(funds.get(&"back_top_up", -1)) == maintenance + int(funds.get(&"rank_tax", 0)), "A09: hidden funds cover the upkeep (and tax) front funds cannot pay")
|
||
_expect(runtime.upkeep_settlement.get_arrears_total() == 0 and int(summary[&"settlement"][&"arrears_total"]) == 0, "no upkeep arrears")
|
||
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 1000 - int(funds[&"back_top_up"]), "front 0, hidden paid the rest")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
func _command(runtime: Variant, type: StringName, payload: Dictionary) -> bool:
|
||
var result: CommandResult = runtime.submit_command(type, payload)
|
||
if not result.is_success():
|
||
push_error("%s: %s %s" % [type, result.message, result.details.get(&"reason", &"")])
|
||
return result.is_success()
|
||
|
||
|
||
func _facility_of(runtime: Variant, definition_id: StringName) -> StringName:
|
||
for facility: FacilityInstance in runtime.facility_service.get_instances():
|
||
if facility.definition_id == definition_id:
|
||
return facility.instance_id
|
||
return &""
|
||
|
||
|
||
func _day_funds(runtime: Variant, day: int) -> Dictionary:
|
||
return runtime.operations_coordinator.get_day_summary(day).get(&"settlement", {}).get(&"funds", {})
|
||
|
||
|
||
func _closed_ledger_text(runtime: Variant, clock: Variant) -> String:
|
||
var panel := OperatingLedgerPanel.new()
|
||
root.add_child(panel)
|
||
panel.setup(runtime, clock)
|
||
panel.select_latest_close()
|
||
var text: String = panel._closed_label.text
|
||
panel.queue_free()
|
||
return text
|
||
|
||
|
||
# F1/F5 and F2 (wages) on the production paths. A front facility (massage_room,
|
||
# staffed by an ordinary hire on a DAY assignment) and the registered hidden-service
|
||
# facility (adult_private_room, underground, its service_attendant on a NIGHT
|
||
# assignment) are built and open; the arriving guests choose and complete the
|
||
# services themselves and the 00:00 closes settle the day.
|
||
func _test_actual_service_funds() -> void:
|
||
var fixture := _new_fixture(5031)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var clock: Variant = fixture[&"clock"]
|
||
var economy: Variant = runtime.economy_service
|
||
# Rank 2 opens the underground, by the original room-zone suite's own route.
|
||
_expect(_command(runtime, &"change_resources", {&"deltas": {&"gold": 5000, &"materials": 100}})
|
||
and _command(runtime, &"update_progression_metrics", {&"metrics": {&"reputation": 25}})
|
||
and _command(runtime, &"set_world_tree_rank", {&"rank": 2}), "rank 2 through the public commands")
|
||
for x: int in range(0, 4):
|
||
_expect(_command(runtime, &"start_excavation", {&"cell": Vector3i(x, -1, 0)}), "excavate (%d, -1)" % x)
|
||
clock.advance_minutes(60)
|
||
# Guests go down by a guest corridor and stairs, staff by a service corridor and stairs.
|
||
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(0, -1, 0)}), "underground guest corridor")
|
||
for x: int in range(1, 4):
|
||
_expect(_command(runtime, &"build_facility", {&"definition_id": &"service_corridor", &"origin": Vector3i(x, -1, 1)}), "underground service corridor (%d, -1)" % x)
|
||
clock.advance_minutes(30)
|
||
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(0, 0, 0)})
|
||
and _command(runtime, &"build_facility", {&"definition_id": &"service_stairs", &"origin": Vector3i(3, 0, 1)}), "stairs down for guests and staff")
|
||
clock.advance_minutes(30)
|
||
_expect(not runtime.path_service.find_path(Vector3i(-2, 0, 0), Vector3i(0, -1, 0)).is_empty(), "guests can walk down to the room")
|
||
_expect(_command(runtime, &"build_facility", {&"definition_id": &"adult_private_room", &"origin": Vector3i(1, -1, 0)})
|
||
and _command(runtime, &"build_facility", {&"definition_id": &"massage_room", &"origin": Vector3i(-6, 0, 0)}), "build the hidden-service and the front-service facility")
|
||
clock.advance_minutes(240)
|
||
var back_room := _facility_of(runtime, &"adult_private_room")
|
||
var front_room := _facility_of(runtime, &"massage_room")
|
||
_expect(not back_room.is_empty() and not front_room.is_empty(), "both facilities complete")
|
||
_expect(_command(runtime, &"hire_staff_candidate", {&"candidate_id": &"staff_petra", &"current_cell": Vector3i(-5, 0, 0)})
|
||
and _command(runtime, &"set_staff_work_assignment", {&"staff_id": &"staff_petra", &"facility_id": front_room, &"template_id": &"DAY"}), "an ordinary hire works the massage room by day")
|
||
# No hire candidate holds the room's service_attendant role (REPAIR_02.md), so it
|
||
# is registered through the Runtime's register_staff command.
|
||
_expect(_command(runtime, &"register_staff", {&"staff_id": &"p05_attendant", &"role_id": &"service_attendant", &"options": {
|
||
&"current_cell": Vector3i(3, 0, 1), &"state": StaffRuntime.State.OFF_DUTY,
|
||
&"permitted_task_tags": [&"service", &"work", &"adult_service"], &"equipment_tags": [&"uniform"],
|
||
&"role_tags": [&"service_attendant"], &"skills": {&"service": 75}}})
|
||
and _command(runtime, &"set_staff_work_assignment", {&"staff_id": &"p05_attendant", &"facility_id": back_room, &"template_id": &"NIGHT"}), "the room's attendant works by night")
|
||
clock.advance_minutes(721 - int(clock.absolute_minute))
|
||
_expect(runtime.facility_service.get_instance(front_room).state == FacilityInstance.State.OPEN, "the massage room opens with its day staff")
|
||
clock.advance_minutes(1141 - int(clock.absolute_minute))
|
||
_expect(runtime.facility_service.get_instance(back_room).state == FacilityInstance.State.OPEN, "the room opens with its night staff")
|
||
clock.advance_minutes(1440 - int(clock.absolute_minute))
|
||
# Day 1: every sale of the massage room went to front funds, of the room to hidden funds.
|
||
var front_sales := 0
|
||
var back_sales := 0
|
||
var misrouted := 0
|
||
var back_payers := {}
|
||
for entry: LedgerEntry in economy.get_entries():
|
||
if entry.category == &"SERVICE_SALE" and entry.source_entity_id == back_room and String(entry.credit_account).begins_with("GUEST_WALLET:guest-auto-"):
|
||
back_payers[entry.credit_account] = true
|
||
if entry.category != &"SERVICE_CASH_SWEEP" or entry.absolute_minute >= 1440:
|
||
continue
|
||
if entry.source_entity_id == front_room and entry.debit_account == FRONT:
|
||
front_sales += entry.amount
|
||
elif entry.source_entity_id == back_room and entry.debit_account == BACK:
|
||
back_sales += entry.amount
|
||
else:
|
||
misrouted += 1
|
||
_expect(front_sales > 0 and back_sales > 0 and misrouted == 0, "front service sales sweep to PLAYER_CASH, hidden ones to BACK_CASH (front %d, hidden %d)" % [front_sales, back_sales])
|
||
_expect(not back_payers.is_empty(), "arriving guests went down to the room and paid for its service themselves (%d guests)" % back_payers.size())
|
||
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
|
||
var funds1 := _day_funds(runtime, 1)
|
||
_expect(int(funds1.get(&"revenue_front", -1)) == front_sales and int(funds1.get(&"revenue_back", -1)) == back_sales, "the 00:00 close reports the day's sales by fund")
|
||
_expect(int(day1[&"category_totals"][&"revenue"]) == front_sales + back_sales, "sales are front + hidden")
|
||
_expect(int(funds1.get(&"rank_tax", -1)) == FinancePolicy.rank_tax(front_sales + back_sales) and int(funds1.get(&"rank_tax", -1)) == (front_sales + back_sales) / 10
|
||
and int(funds1.get(&"rank_tax_paid", -1)) == int(funds1[&"rank_tax"]), "A10: the close taxes 10% of both sales, paid")
|
||
_expect(int(funds1.get(&"back_top_up", -1)) == 0 and economy.get_balance(BACK) == back_sales and _fund_delta(runtime, BACK, 0, 1440) == back_sales, "hidden funds hold exactly the day's hidden sales")
|
||
# Day 2: the front fund is lowered to 5 by a ledger fixture before the close, so the
|
||
# close must pay the real wages, upkeep and tax with hidden funds.
|
||
clock.advance_minutes(1440 + 1430 - int(clock.absolute_minute))
|
||
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 5, &"OPERATING_EXPENSE", "service.d2.drain"), "front funds lowered to 5")
|
||
var back_before: int = economy.get_balance(BACK)
|
||
clock.advance_minutes(2880 - int(clock.absolute_minute))
|
||
var day2: Dictionary = runtime.operations_coordinator.get_day_summary(2)
|
||
var totals2: Dictionary = day2.get(&"category_totals", {})
|
||
var funds2 := _day_funds(runtime, 2)
|
||
var due := int(totals2.get(&"wages", 0)) + int(totals2.get(&"maintenance", 0)) + int(totals2.get(&"taxes_and_interest", 0))
|
||
_expect(int(totals2.get(&"wages", 0)) > 0 and int(funds2.get(&"revenue_back", 0)) > 0, "day 2 has real wages and hidden sales")
|
||
_expect(int(funds2.get(&"back_top_up", -1)) == due - 5, "A09: hidden funds cover the wages, upkeep and tax the 5 front funds cannot pay")
|
||
_expect(int(day2[&"settlement"].get(&"unpaid_after_total", -1)) == 0 and int(day2[&"settlement"][&"arrears_total"]) == 0, "the wages are paid in full, nothing is in arrears")
|
||
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == back_before - (due - 5), "front 0, hidden funds paid the rest")
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the two service days restore exactly")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
func _has_key(dictionary: Dictionary, key: String) -> bool:
|
||
return dictionary.has(StringName(key)) or dictionary.has(key)
|
||
|
||
|
||
func _set_key(dictionary: Dictionary, key: String, value: Variant) -> void:
|
||
if dictionary.has(key):
|
||
dictionary[key] = value
|
||
else:
|
||
dictionary[StringName(key)] = value
|
||
|
||
|
||
func _erase_key(dictionary: Dictionary, key: String) -> void:
|
||
dictionary.erase(StringName(key))
|
||
dictionary.erase(key)
|
||
|
||
|
||
# The saved fund report of a day inside a Runtime snapshot; changing it changes the snapshot.
|
||
func _saved_funds(snapshot: Dictionary, day: int) -> Dictionary:
|
||
for summary: Dictionary in snapshot[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"day_summaries"]:
|
||
if int(summary.get(&"day", summary.get("day", -1))) == day:
|
||
var settlement: Dictionary = summary.get(&"settlement", summary.get("settlement", {}))
|
||
return settlement.get(&"funds", settlement.get("funds", {}))
|
||
return {}
|
||
|
||
|
||
# F2 (automatic purchase), F4 (interest after a repayment) and the day's top-up
|
||
# total: the warehouse's automatic order and the repayment command run on their
|
||
# production paths with too little front funds, then the 00:00 close. A report
|
||
# saved before the definition mark is then loaded by its own definition.
|
||
func _test_operation_top_ups_and_day_total() -> void:
|
||
var fixture := _new_fixture(5032)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var clock: Variant = fixture[&"clock"]
|
||
var economy: Variant = runtime.economy_service
|
||
_expect(_command(runtime, &"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}), "build a warehouse")
|
||
clock.advance_minutes(180)
|
||
var warehouse := _facility_of(runtime, &"warehouse")
|
||
_expect(not warehouse.is_empty(), "the warehouse completes")
|
||
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "ops.borrow"), "fixture principal 1000")
|
||
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 2, &"OPERATING_EXPENSE", "ops.drain")
|
||
and _sale(fixture, BACK_SALES, BACK, 1000, "ops.back"), "front 2, hidden 1000")
|
||
_expect(_command(runtime, &"set_supply_auto_purchase", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 10}), "an automatic order for 10 raw food")
|
||
clock.advance_minutes(1)
|
||
var plan: Dictionary = runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD)
|
||
var receipt := SupplyAutoPurchasePlans.receipt_from_ledger(economy, warehouse, SupplyCatalog.RAW_FOOD, clock.absolute_minute, StringName(plan.get(&"last_transaction_id", &"")))
|
||
_expect(plan.get(&"last_result") == &"PURCHASED" and receipt == {&"item_id": String(SupplyCatalog.RAW_FOOD), &"quantity": 10, &"unit_price": 3, &"total_cost": 30}, "the automatic order buys 10 for 30 and its receipt verifies")
|
||
var order_key := SupplyAutoPurchasePlans.order_key(warehouse, SupplyCatalog.RAW_FOOD, clock.absolute_minute)
|
||
var order_top_up: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(StringName("finance.back_top_up.purchase." + String(order_key))))
|
||
_expect(order_top_up.size() == 1 and order_top_up[0].category == FinancePolicy.TOP_UP_CATEGORY and order_top_up[0].amount == 28, "A09: hidden funds cover the 28 the automatic order's front funds cannot pay")
|
||
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 972, "front 0, hidden 972 after the automatic order")
|
||
var repaid: CommandResult = runtime.submit_command(&"repay_loan_principal", {&"loan_id": &"p05.fixture", &"amount": 200}, &"player", &"p05.fixture", 50, -1, &"p05.ops.repay")
|
||
_expect(repaid.is_success() and int(repaid.details.get(&"outstanding_principal", -1)) == 800, "the repayment command pays 200 of the 1000")
|
||
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 772, "hidden funds paid the repayment")
|
||
clock.advance_minutes(1440 - int(clock.absolute_minute))
|
||
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
|
||
var funds := _day_funds(runtime, 1)
|
||
_expect(int(funds.get(&"loan_interest", -1)) == 8 and int(funds.get(&"loan_interest_paid", -1)) == 8, "A11: the close charges 1% of the 800 left after the repayment, 8, paid")
|
||
_expect(int(funds.get(&"rank_tax", -1)) == 100 and int(funds.get(&"rank_tax_paid", -1)) == 100, "the hidden sale's tax is paid")
|
||
var close_top_up := 0
|
||
for entry: LedgerEntry in economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(&"finance.back_top_up.day.00000001")):
|
||
close_top_up += entry.amount
|
||
var every_top_up := 0
|
||
for entry: LedgerEntry in economy.get_entries():
|
||
if entry.category == FinancePolicy.TOP_UP_CATEGORY:
|
||
every_top_up += entry.amount
|
||
_expect(close_top_up == 8 + 100 + int(day1[&"category_totals"][&"maintenance"]), "the close tops up the interest, tax and upkeep")
|
||
var total := 28 + 200 + close_top_up
|
||
_expect(int(funds.get(&"back_top_up", -1)) == total and every_top_up == total, "funds.back_top_up is the day's every top-up: the order, the repayment and the close")
|
||
_expect(int(funds.get(&"fund_report_version", -1)) == 2, "the report carries its definition version")
|
||
_expect(economy.get_balance(BACK) == 1000 - total and economy.get_balance(FRONT) == 0, "hidden funds fell by exactly the day's top-ups")
|
||
_expect(_closed_ledger_text(runtime, clock).contains("裏金から表金へ補填 %dG" % total), "the ledger's close shows the day's every top-up")
|
||
# Construction is outside A09: with no front funds it is refused and the hidden fund is not used.
|
||
var refused: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)})
|
||
var top_ups_after := 0
|
||
for entry: LedgerEntry in economy.get_entries():
|
||
if entry.category == FinancePolicy.TOP_UP_CATEGORY:
|
||
top_ups_after += entry.amount
|
||
_expect(not refused.is_success() and economy.get_balance(BACK) == 1000 - total and top_ups_after == total,
|
||
"A09 does not cover construction: refused with front 0, hidden funds untouched (%s)" % refused.details.get(&"reason", refused.message))
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the day with three top-ups restores exactly")
|
||
# A report saved before the mark (back_top_up = the close's own top-up only) loads by that definition.
|
||
var old := saved.duplicate(true)
|
||
var old_funds := _saved_funds(old, 1)
|
||
_expect(_has_key(old_funds, "fund_report_version"), "the saved report holds the mark")
|
||
_erase_key(old_funds, "fund_report_version")
|
||
_set_key(old_funds, "back_top_up", close_top_up)
|
||
_expect(runtime.restore_snapshot(old), "an unmarked report with the close-only top-up loads")
|
||
var loaded := _day_funds(runtime, 1)
|
||
_expect(int(loaded.get(&"back_top_up", -1)) == close_top_up and not _has_key(loaded, "fund_report_version"), "the loaded old report stays as it was saved")
|
||
_expect(_closed_ledger_text(runtime, clock).contains("裏金から表金へ補填(締めの時の分のみ) %dG" % close_top_up), "the ledger labels the old report's close-only top-up")
|
||
# No looser check: each definition accepts only its own value.
|
||
for case: Array in [[null, total, "an unmarked report with the day total"], [2, close_top_up, "a marked report with the close-only top-up"], [3, total, "an unknown mark"]]:
|
||
var bad := saved.duplicate(true)
|
||
var bad_funds := _saved_funds(bad, 1)
|
||
if case[0] == null:
|
||
_erase_key(bad_funds, "fund_report_version")
|
||
else:
|
||
_set_key(bad_funds, "fund_report_version", case[0])
|
||
_set_key(bad_funds, "back_top_up", case[1])
|
||
var before: Dictionary = runtime.create_snapshot()
|
||
_expect(not runtime.restore_snapshot(bad) and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "%s is rejected and changes nothing" % case[2])
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the current save loads again")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
# F2 (legacy calendar): in a save from before the 00:00 close the upkeep is paid at
|
||
# calendar midnight and the financial day closes at 06:00 (as in
|
||
# tests/mvp/test_upkeep_legacy_midnight.gd). With no front funds the midnight upkeep
|
||
# is topped up from hidden funds, and that 06:00 day's report counts it.
|
||
func _test_legacy_midnight_top_up() -> void:
|
||
var fixture := _new_fixture(5033)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var clock: Variant = fixture[&"clock"]
|
||
var economy: Variant = runtime.economy_service
|
||
var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"guest_corridor")
|
||
var old_rate := definition.maintenance_gold
|
||
definition.maintenance_gold = 1440
|
||
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}), "build a corridor with upkeep")
|
||
runtime.operations_coordinator._legacy_calendar_last_day = 1
|
||
clock.advance_minutes(1079)
|
||
var legacy: Dictionary = runtime.create_snapshot()
|
||
var data: Dictionary = legacy[&"systems"][&"runtime"]
|
||
data[&"runtime_schema_version"] = 9
|
||
preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data)
|
||
data[&"operations"][&"coordinator"][&"schema_version"] = 3
|
||
data[&"operations"][&"coordinator"].erase(&"legacy_calendar_last_day")
|
||
data[&"operations"][&"coordinator"].erase(&"legacy_history_last_day")
|
||
data[&"operations"][&"coordinator"].erase(&"legacy_principal_profit_last_day")
|
||
_expect(runtime.restore_snapshot(legacy), "the legacy-calendar save loads at 23:59")
|
||
# The load replaces the Runtime's ledger service.
|
||
economy = runtime.economy_service
|
||
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT), &"OPERATING_EXPENSE", "legacy.drain")
|
||
and _sale(fixture, BACK_SALES, BACK, 500, "legacy.back"), "front 0, hidden 500")
|
||
clock.advance_minutes(1)
|
||
var midnight: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(&"finance.back_top_up.midnight.1440"))
|
||
_expect(midnight.size() == 1 and midnight[0].category == FinancePolicy.TOP_UP_CATEGORY and midnight[0].amount == 1, "A09: hidden funds cover the 00:00 upkeep front funds cannot pay")
|
||
_expect(economy.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1 and runtime.upkeep_settlement.get_arrears_total() == 0
|
||
and economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 499, "the midnight upkeep is paid, nothing in arrears")
|
||
clock.advance_minutes(360)
|
||
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
|
||
var funds := _day_funds(runtime, 1)
|
||
_expect(int(day1.get(&"closed_at", -1)) == 1800 and int(funds.get(&"rank_tax", -1)) == 50 and int(funds.get(&"rank_tax_paid", -1)) == 50, "the 06:00 close taxes the hidden sale, paid")
|
||
_expect(int(funds.get(&"back_top_up", -1)) == 51 and economy.get_balance(BACK) == 449, "the 06:00 day's report counts the midnight top-up and the close's")
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the legacy day restores exactly")
|
||
definition.maintenance_gold = old_rate
|
||
_free_fixture(fixture)
|
||
|
||
|
||
# F3: the day's tax that front and hidden funds cannot pay stays payable, and the
|
||
# next close pays that older tax before the new one.
|
||
func _test_tax_arrears_next_day() -> void:
|
||
var fixture := _new_fixture(5034)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var economy: Variant = runtime.economy_service
|
||
_advance_to(fixture, 420)
|
||
_expect(_sale(fixture, FRONT_SALES, FRONT, 200, "tax.d1") and _move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT), &"OPERATING_EXPENSE", "tax.d1.drain"), "day 1 sale 200, then no funds")
|
||
_advance_to(fixture, 1440)
|
||
var funds1 := _day_funds(runtime, 1)
|
||
_expect(int(funds1.get(&"rank_tax", -1)) == 20 and int(funds1.get(&"rank_tax_paid", -1)) == 0 and int(funds1.get(&"rank_tax_arrears", -1)) == 20, "A09: the unpaid tax carries over")
|
||
_expect(int(runtime.operations_coordinator.get_day_summary(1)[&"settlement"][&"arrears_total"]) == 20 and int(runtime.get_operations_report().get(&"arrears_total", -1)) == 20, "the arrears include the tax")
|
||
# F7 with arrears: the ledger shows the unpaid tax in the close and in today's cash section.
|
||
var panel := OperatingLedgerPanel.new()
|
||
root.add_child(panel)
|
||
panel.setup(runtime, fixture[&"clock"])
|
||
panel.select_latest_close()
|
||
_expect(panel._closed_label.text.contains("未払い 税 20G・利息 0G") and panel._cash_label.text.contains("(未払い 20G)"), "the ledger shows the unpaid tax")
|
||
panel.queue_free()
|
||
# The closed day's arrears_total includes the tax arrears, so the Operations gate holds
|
||
# construction and investment until they are paid.
|
||
var blocked: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)})
|
||
_expect(not blocked.is_success() and StringName(blocked.details.get(&"reason", &"")) == &"ARREARS_REMAIN" and int(blocked.details.get(&"arrears_total", -1)) == 20,
|
||
"tax arrears stop construction (ARREARS_REMAIN, arrears 20)")
|
||
_advance_to(fixture, 1440 + 600)
|
||
_expect(_sale(fixture, FRONT_SALES, FRONT, 100, "tax.d2"), "day 2 sale 100")
|
||
_advance_to(fixture, 2880)
|
||
var funds2 := _day_funds(runtime, 2)
|
||
_expect(int(funds2.get(&"rank_tax", -1)) == 10 and int(funds2.get(&"rank_tax_paid", -1)) == 30 and int(funds2.get(&"rank_tax_arrears", -1)) == 0, "day 2 pays the older 20 and the new 10")
|
||
_expect(economy.get_balance(FRONT) == 70 and economy.get_balance(FinancePolicy.RANK_TAX_PAYABLE) == 0, "front 70, no tax payable left")
|
||
var built: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)})
|
||
_expect(built.is_success() and economy.get_balance(FRONT) == 50, "once the tax is paid, construction is allowed again (20 from front funds)")
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the arrears days restore exactly")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
# F2 through the ordinary ledger: the panel's repayment controls cap by front + hidden
|
||
# funds (A09), and a specified or full repayment that front funds cannot pay is one
|
||
# transaction: the front fund pays first and the hidden fund covers the rest.
|
||
func _test_ledger_repayment_uses_hidden_funds() -> void:
|
||
var fixture := _new_fixture(5035)
|
||
var runtime: Variant = fixture[&"runtime"]
|
||
var clock: Variant = fixture[&"clock"]
|
||
var economy: Variant = runtime.economy_service
|
||
_advance_to(fixture, 420)
|
||
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "panel.borrow"), "fixture principal 1000")
|
||
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 20, &"OPERATING_EXPENSE", "panel.drain")
|
||
and _sale(fixture, BACK_SALES, BACK, 300, "panel.back"), "front 20, hidden 300")
|
||
var panel := OperatingLedgerPanel.new()
|
||
root.add_child(panel)
|
||
panel.setup(runtime, clock)
|
||
panel.loan_amount.text = "200"
|
||
panel.loan_amount.text_changed.emit("200")
|
||
var hint: String = panel.get_node("LoanRepaymentHint").text
|
||
_expect(not panel.repay_button.disabled and hint.contains("表金 20G・裏金 300G(計 320G)") and hint.contains("裏金から補填"), "a 200 repayment over front 20 is offered with the hidden fund")
|
||
var key := StringName("ui.loan.repay.%d" % int(runtime.get_loan_principal_report()[&"next_command_id"]))
|
||
panel.repay_button.pressed.emit()
|
||
var entries: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(key))
|
||
_expect(entries.size() == 2 and entries[0].category == FinancePolicy.TOP_UP_CATEGORY and entries[0].amount == 180 and entries[1].category == &"LOAN_PRINCIPAL_REPAID" and entries[1].amount == 200,
|
||
"one transaction: 180 hidden top-up, then the 200 principal")
|
||
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 120 and -economy.get_balance(LOAN) == 800 and panel.get_node("LoanRepaymentResult").text.contains("200Gを返済"),
|
||
"front 20 first, hidden 180 for the rest; 800 left")
|
||
_expect(panel.repay_all_button.disabled and panel.get_node("LoanRepaymentHint").text.contains("完済には表金と裏金の合計が不足しています"), "full repayment of 800 waits: front + hidden is 120")
|
||
panel.loan_amount.text = "500"
|
||
panel.loan_amount.text_changed.emit("500")
|
||
_expect(panel.repay_button.disabled, "500 over front + hidden 120 is not offered")
|
||
var before: Dictionary = runtime.create_snapshot()
|
||
panel.submit_repayment(true)
|
||
panel.submit_repayment(false)
|
||
_expect(var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "pressed anyway, the unaffordable repayments change nothing")
|
||
_expect(_sale(fixture, BACK_SALES, BACK, 700, "panel.back2"), "hidden funds reach 820")
|
||
panel.refresh()
|
||
_expect(not panel.repay_all_button.disabled, "full repayment of 800 is offered with front 0 and hidden 820")
|
||
var full_key := StringName("ui.loan.repay.%d" % int(runtime.get_loan_principal_report()[&"next_command_id"]))
|
||
panel.repay_all_button.pressed.emit()
|
||
var full: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(full_key))
|
||
_expect(full.size() == 2 and full[0].category == FinancePolicy.TOP_UP_CATEGORY and full[0].amount == 800 and full[1].amount == 800,
|
||
"the full repayment is one transaction, all from hidden funds")
|
||
_expect(economy.get_balance(LOAN) == 0 and economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 20 and fixture[&"state"].get_resource(&"gold") == 0, "the loan is repaid; front 0, hidden 20, Gold mirrors front funds")
|
||
panel.queue_free()
|
||
var saved: Dictionary = runtime.create_snapshot()
|
||
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the panel repayments restore exactly")
|
||
_free_fixture(fixture)
|
||
|
||
|
||
# F7: the ordinary ledger panel reads both funds, the rank tax, the interest, the
|
||
# top-ups and their arrears from the same ledger and daily closes.
|
||
func _check_ledger_panel(runtime: Variant, clock: Variant) -> void:
|
||
var panel := OperatingLedgerPanel.new()
|
||
root.add_child(panel)
|
||
panel.setup(runtime, clock)
|
||
var report: Dictionary = panel.get_report()
|
||
_expect(int(report.get(&"cash", -1)) == runtime.economy_service.get_balance(FRONT)
|
||
and int(report.get(&"back_cash", -1)) == runtime.economy_service.get_balance(BACK), "the panel reports both funds")
|
||
_expect(int(report.get(&"finance_arrears", -1)) == 0, "the panel reports no tax or interest arrears after day 4")
|
||
var cash_text: String = panel._cash_label.text
|
||
_expect(cash_text.contains("裏金 %dG" % runtime.economy_service.get_balance(BACK)) and cash_text.contains("本日の税") and cash_text.contains("利息"), "the cash section shows the hidden fund, tax and interest")
|
||
panel.select_latest_close()
|
||
var close_text: String = panel._closed_label.text
|
||
_expect(close_text.contains("うち表の売上 100G / 裏の売上 0G") and close_text.contains("世界樹ランク税 10G(支払 10G)") \
|
||
and close_text.contains("利息 10G(支払 20G)") and close_text.contains("未払い 税 0G・利息 0G"), "the day 4 close shows sales by fund, tax, interest and arrears")
|
||
panel.queue_free()
|
||
|
||
|
||
# F7 through ordinary operation: after a real 00:00 close on the game's own
|
||
# Runtime and clock, Main's 帳簿 navigation button opens the ledger.
|
||
func _test_ledger_opened_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, 5028)
|
||
clock.set_paused(true)
|
||
root.size = Vector2i(1440, 900)
|
||
var main: Control = load("res://scenes/main/main.tscn").instantiate()
|
||
root.add_child(main)
|
||
await process_frame
|
||
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||
for account: StringName in [FRONT_SALES, BACK_SALES, PAYER]:
|
||
runtime.economy_service.register_account(account)
|
||
runtime.economy_service.register_account(LOAN, true)
|
||
_expect(runtime.economy_service.initialize_account(PAYER, 100000, clock.absolute_minute, &"p05.fixture.payer").is_success(), "game Runtime fixture payer funded")
|
||
clock.advance_minutes(60)
|
||
var fixture := {&"clock": clock, &"state": state, &"runtime": runtime}
|
||
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "ui.borrow") and _sale(fixture, FRONT_SALES, FRONT, 300, "ui.front")
|
||
and _sale(fixture, BACK_SALES, BACK, 500, "ui.back"), "day 1 loan and sales on the game Runtime")
|
||
clock.advance_minutes(1440 - int(clock.absolute_minute))
|
||
_expect(not runtime.operations_coordinator.get_day_summary(1).is_empty(), "the normal clock closes day 1 at 00:00")
|
||
var ledger_button: Button = (main.get("navigation_buttons") as Dictionary)[&"ledger"]
|
||
ledger_button.pressed.emit()
|
||
var panel: OperatingLedgerPanel = main.get("operating_ledger_panel")
|
||
_expect(panel.visible and ledger_button.button_pressed, "the 帳簿 button opens the ledger")
|
||
var report: Dictionary = panel.get_report()
|
||
_expect(int(report.get(&"cash", -1)) == 6210 and int(report.get(&"back_cash", -1)) == 500, "the opened ledger reports front 6210 and hidden 500")
|
||
# The day 1 tax and interest are paid at the 00:00 that opens day 2.
|
||
var cash_text: String = panel._cash_label.text
|
||
_expect(cash_text.contains("裏金 500G(本日の入金 +0G / 表金へ補填 −0G)") and cash_text.contains("本日の税 80G / 利息 10G(未払い 0G)"), "the opened ledger shows the hidden fund, today's tax and interest")
|
||
var close_text: String = panel._closed_label.text
|
||
_expect(close_text.contains("うち表の売上 300G / 裏の売上 500G") and close_text.contains("世界樹ランク税 80G(支払 80G)/ 利息 10G(支払 10G)")
|
||
and close_text.contains("裏金から表金へ補填 0G / 未払い 税 0G・利息 0G"), "the opened ledger shows the day 1 close by fund with tax and interest")
|
||
var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
|
||
_expect(bool(cleanup.get(&"passed", false)), "ordinary Main release with audio cleanup")
|