diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 13daeb7..5d1b289 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -909,6 +909,9 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false if not FacilityUpkeepSettlement.prepare_accounts(restored_economy) or not restored_upkeep.start_at(restored_minute)[&"ok"]: return false + # A12: a save made before the funds were split reads every Gold as front funds. + if not FinancePolicy.prepare_accounts(restored_economy): + return false var saved_resources: Variant = snapshot[&"game_state"].get(&"resources", null) if not saved_resources is Dictionary \ or int(saved_resources.get(&"gold", saved_resources.get("gold", -1))) \ @@ -1935,11 +1938,14 @@ func _operations_closed_arrears_total() -> int: var last_close := 0 if operations_coordinator != null and operations_coordinator.get_open_financial_day() > 1: last_close = operations_coordinator.get_daily_close_minute(operations_coordinator.get_open_financial_day() - 1) - return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_through(last_close) + return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_through(last_close) \ + + _finance_arrears_through(economy_service, last_close) func _operations_total_arrears() -> int: - return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_total() + return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_total() \ + - economy_service.get_balance(FinancePolicy.RANK_TAX_PAYABLE) \ + - economy_service.get_balance(FinancePolicy.LOAN_INTEREST_PAYABLE) func _advance_operations_time(absolute_minute: int) -> void: @@ -2504,6 +2510,13 @@ func _operations_midnight_upkeep_boundary(payload: Dictionary, phase: StringName var prepared := _run_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", payload, OPERATIONS_TEST_PREPARE_PHASE, token) if not bool(prepared.get(&"ok", false)): return prepared + var midnight_due := _upkeep_debt_through(int(minute)) + if midnight_due < 0: + return {&"ok": false, &"reason": &"UPKEEP_DUE_PREVIEW_FAILED"} + var midnight_top_up := top_up_front_fund(midnight_due, int(minute), StringName("finance.back_top_up.midnight.%d" % int(minute))) + if not midnight_top_up.is_success() or (int(midnight_top_up.details.get(&"amount", 0)) > 0 \ + and not _stage_operations_economy_notification(midnight_top_up)): + return {&"ok": false, &"reason": &"BACK_FUND_TOP_UP_FAILED"} var result := _settle_upkeep_through(int(minute)) if not bool(result[&"ok"]): return result @@ -2524,10 +2537,29 @@ func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dicti if not bool(prepare_hook.get(&"ok", false)): return prepare_hook var day := int(payload[&"day"]) + var boundary_minute := int(payload[&"boundary_minute"]) var preview := staff_service.preview_daily_payroll(day) if not bool(preview.get(&"ok", false)) \ or not preview.get(&"rows", null) is Array: return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_FAILED"} + # A09: the hidden fund first covers what the front fund cannot pay of the day's + # wages, upkeep, rank tax and loan interest (arrears included). + var finance_due := _daily_finance_due(economy_service, int(payload[&"period_start_minute"]), boundary_minute) + if not bool(finance_due[&"ok"]): + return finance_due + var payroll_due := 0 + for raw_due: Variant in preview[&"rows"]: + if raw_due is Dictionary and typeof((raw_due as Dictionary).get(&"total_due", null)) == TYPE_INT: + payroll_due += maxi(int(raw_due[&"total_due"]), 0) + var upkeep_due := _upkeep_debt_through(boundary_minute) + if upkeep_due < 0: + return {&"ok": false, &"reason": &"UPKEEP_DUE_PREVIEW_FAILED"} + var top_up := top_up_front_fund(payroll_due + upkeep_due + int(finance_due[&"due_total"]), boundary_minute, + StringName("finance.back_top_up.day.%08d" % day)) + if not top_up.is_success(): + return {&"ok": false, &"reason": &"BACK_FUND_TOP_UP_FAILED", &"result": top_up.to_dictionary()} + if int(top_up.details.get(&"amount", 0)) > 0 and not _stage_operations_economy_notification(top_up): + return {&"ok": false, &"reason": &"BACK_FUND_TOP_UP_NOTIFICATION_STAGE_FAILED"} var available_gold := economy_service.get_balance(PLAYER_CASH_ACCOUNT) var remaining_gold := available_gold var payments: Dictionary = {} @@ -2592,6 +2624,17 @@ func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dicti if not bool(upkeep_result[&"ok"]): return upkeep_result var upkeep_paid := int(upkeep_result[&"paid_total"]) + var rank_tax := _post_daily_finance_charge(day, boundary_minute, int(finance_due[&"rank_tax"]), + FinancePolicy.RANK_TAX_EXPENSE, FinancePolicy.RANK_TAX_PAYABLE, + FinancePolicy.RANK_TAX_CATEGORY, FinancePolicy.RANK_TAX_PAYMENT_CATEGORY, "rank_tax") + if not bool(rank_tax[&"ok"]): + return rank_tax + var loan_interest := _post_daily_finance_charge(day, boundary_minute, int(finance_due[&"loan_interest"]), + FinancePolicy.LOAN_INTEREST_EXPENSE, FinancePolicy.LOAN_INTEREST_PAYABLE, + FinancePolicy.LOAN_INTEREST_CATEGORY, FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY, "loan_interest") + if not bool(loan_interest[&"ok"]): + return loan_interest + var finance_paid := int(rank_tax[&"paid"]) + int(loan_interest[&"paid"]) var financials := _build_operations_daily_financials( int(payload[&"period_start_minute"]), int(payload[&"period_end_minute_exclusive"]), @@ -2603,14 +2646,19 @@ func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dicti var arrears_total := int(settlement.details.get(&"unpaid_after_total", -1)) if arrears_total < 0 \ or arrears_total != _staff_payroll_arrears_total(staff_service) \ - or economy_service.get_balance(PLAYER_CASH_ACCOUNT) != available_gold - expected_paid_total - upkeep_paid \ + or economy_service.get_balance(PLAYER_CASH_ACCOUNT) != available_gold - expected_paid_total - upkeep_paid - finance_paid \ or int(_state.get_resource(&"gold")) != economy_service.get_balance(PLAYER_CASH_ACCOUNT) \ or not bool(economy_service.validate_ledger().get(&"ok", false)) \ or not bool(staff_service.validate_invariants().get(&"ok", false)): return {&"ok": false, &"reason": &"DAILY_CLOSE_POSTCONDITION_FAILED"} + var funds := _daily_fund_report(economy_service, day, int(payload[&"period_start_minute"]), boundary_minute) + if funds.is_empty(): + return {&"ok": false, &"reason": &"DAILY_FUND_REPORT_FAILED"} var settlement_report := settlement.details.duplicate(true) settlement_report[&"upkeep_arrears_total"] = upkeep_settlement.get_arrears_total() - settlement_report[&"arrears_total"] = arrears_total + int(settlement_report[&"upkeep_arrears_total"]) + settlement_report[&"funds"] = funds + settlement_report[&"arrears_total"] = arrears_total + int(settlement_report[&"upkeep_arrears_total"]) \ + + int(funds[&"rank_tax_arrears"]) + int(funds[&"loan_interest_arrears"]) return { &"ok": true, &"category_totals": financials[&"category_totals"], @@ -2647,6 +2695,8 @@ func _build_operations_daily_financials( &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", + FinancePolicy.TOP_UP_CATEGORY, FinancePolicy.RANK_TAX_PAYMENT_CATEGORY, + FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY, ] var category_mapping := { &"SERVICE_SALE": &"revenue", @@ -2660,6 +2710,8 @@ func _build_operations_daily_financials( &"EVENT_COST_COMMITTED": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods", &"MAINTENANCE_ACCRUAL": &"maintenance", + FinancePolicy.RANK_TAX_CATEGORY: &"taxes_and_interest", + FinancePolicy.LOAN_INTEREST_CATEGORY: &"taxes_and_interest", &"LOAN_PRINCIPAL_BORROWED": &"financing_principal_inflows", &"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows", } @@ -3551,8 +3603,22 @@ func _operations_payroll_crosslinks_are_valid( summary_settlement.erase(&"upkeep_arrears_total") elif upkeep_debt != 0: return false + var finance_arrears := 0 + var expected_funds := _daily_fund_report(economy, day, coordinator.get_financial_period_start(day), + coordinator.get_daily_close_minute(day)) + if expected_funds.is_empty(): + return false + if summary_settlement.has(&"funds"): + if not _strict_variants_equal(_operations_snapshot_comparison_value(summary_settlement[&"funds"]), + _operations_snapshot_comparison_value(expected_funds)): + return false + finance_arrears = int(expected_funds[&"rank_tax_arrears"]) + int(expected_funds[&"loan_interest_arrears"]) + summary_settlement.erase(&"funds") + elif bool(expected_funds[&"has_finance_postings"]): + # Days closed before the funds were split carry no fund report and no postings. + return false if int(summary_settlement.get(&"arrears_total", -1)) \ - != int(staff_record.get(&"unpaid_after_total", -2)) + upkeep_debt \ + != int(staff_record.get(&"unpaid_after_total", -2)) + upkeep_debt + finance_arrears \ or summary_settlement.get(&"replayed", null) != false: return false summary_settlement.erase(&"arrears_total") @@ -3626,15 +3692,20 @@ func repay_loan_principal( return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "The loan liability does not exist.") var remaining := -economy_service.get_balance(liability) var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT) - if amount > remaining or amount > cash or _state.get_resource(&"gold") != cash: + if amount > remaining or amount > FinancePolicy.available_funds(economy_service) or _state.get_resource(&"gold") != cash: return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Principal payment exceeds the outstanding principal or available Gold, or its mirror is inconsistent.") - # The single balanced posting is atomic. Defer receipts until the Gold - # mirror is synchronized, also respecting any enclosing effect scope. + # A09: what the front fund cannot pay comes from the hidden fund in the same + # balanced posting, so the payment and its top-up are atomic. Defer receipts + # until the Gold mirror is synchronized, also respecting any enclosing effect scope. + var minute := _runtime_event_minute() + var entries: Array = [] + if amount > cash: + entries.append(LedgerEntry.new().configure(&"", minute, PLAYER_CASH_ACCOUNT, FinancePolicy.BACK_CASH, + amount - cash, FinancePolicy.TOP_UP_CATEGORY, loan_id, &"", &"finance.back_fund.top_up")) + entries.append(LedgerEntry.new().configure(&"", minute, liability, PLAYER_CASH_ACCOUNT, + amount, &"LOAN_PRINCIPAL_REPAID", loan_id)) economy_service.begin_notification_scope() - var result := economy_service.transfer( - PLAYER_CASH_ACCOUNT, liability, amount, _runtime_event_minute(), - &"LOAN_PRINCIPAL_REPAID", loan_id, idempotency_key - ) + var result := economy_service.post(entries, idempotency_key) if result.is_success(): _state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT)) result.details[&"loan_id"] = loan_id @@ -3644,6 +3715,125 @@ func repay_loan_principal( return result +## A09: what the front fund cannot pay is covered from the hidden fund first. The move +## is posted on its own (BACK_FUND_TOP_UP) and is neither income nor expense. +func top_up_front_fund(due: int, minute: int, idempotency_key: StringName) -> CommandResult: + var front := economy_service.get_balance(PLAYER_CASH_ACCOUNT) + var amount := mini(maxi(due - front, 0), economy_service.get_balance(FinancePolicy.BACK_CASH)) + if amount <= 0: + return CommandResult.succeeded(0, {&"amount": 0}) + var result := economy_service.transfer(FinancePolicy.BACK_CASH, PLAYER_CASH_ACCOUNT, amount, minute, + FinancePolicy.TOP_UP_CATEGORY, &"", idempotency_key, &"finance.back_fund.top_up", &"", false) + # Callers publish the notification, which also refreshes the Gold projection. + if result.is_success(): + result.details[&"amount"] = amount + return result + + +## Whole Gold the closed upkeep periods through the minute would owe, arrears +## included; -1 if a period cannot be planned. Nothing is changed. +func _upkeep_debt_through(minute: int) -> int: + var periods: Array = [] + for period: Dictionary in facility_upkeep.get_closed_periods_from(upkeep_settlement.get_settled_period_count()): + if int(period[&"period_end"]) <= minute: + periods.append(period) + return upkeep_settlement.preview_debt(periods) + + +## A10/A11: the closed day's rank tax and loan interest, with their older arrears. +func _daily_finance_due(economy: EconomyService, period_start: int, boundary_minute: int) -> Dictionary: + var totals: Variant = _operations_expected_category_totals(economy, period_start, boundary_minute, 0) + if totals == null: + return {&"ok": false, &"reason": &"DAILY_SALES_UNAVAILABLE"} + var tax := FinancePolicy.rank_tax(int(totals[&"revenue"])) + var interest := FinancePolicy.loan_interest(FinancePolicy.outstanding_principal(economy)) + return {&"ok": true, &"rank_tax": tax, &"loan_interest": interest, + &"due_total": tax + interest - economy.get_balance(FinancePolicy.RANK_TAX_PAYABLE) + - economy.get_balance(FinancePolicy.LOAN_INTEREST_PAYABLE)} + + +## Accrues a daily charge at the end of the closed day and pays its older arrears +## first, then the new amount, from the front fund; the rest stays payable. +func _post_daily_finance_charge(day: int, boundary_minute: int, new_amount: int, expense: StringName, + payable: StringName, category: StringName, payment_category: StringName, kind: String) -> Dictionary: + var due := new_amount - economy_service.get_balance(payable) + var paid := mini(due, economy_service.get_balance(PLAYER_CASH_ACCOUNT)) + var source := StringName("finance-day-%08d" % day) + var entries: Array = [] + if new_amount > 0: + entries.append(LedgerEntry.new().configure(&"", boundary_minute - 1, expense, payable, new_amount, + category, source, &"", StringName("finance.%s.accrued" % kind))) + if paid > 0: + entries.append(LedgerEntry.new().configure(&"", boundary_minute, payable, PLAYER_CASH_ACCOUNT, paid, + payment_category, source, &"", StringName("finance.%s.paid" % kind))) + if not entries.is_empty(): + var posted := economy_service.post(entries, StringName("finance.%s.day.%08d" % [kind, day]), &"", false) + if not posted.is_success(): + return {&"ok": false, &"reason": &"FINANCE_LEDGER_POST_FAILED", &"result": posted.to_dictionary()} + if not _stage_operations_economy_notification(posted): + return {&"ok": false, &"reason": &"FINANCE_NOTIFICATION_STAGE_FAILED"} + return {&"ok": true, &"new": new_amount, &"paid": paid, &"arrears": due - paid} + + +## The fund side of a closed day, derived only from the ledger, so the live close +## and a restored save produce the same report. +func _daily_fund_report(economy: EconomyService, day: int, period_start: int, boundary_minute: int) -> Dictionary: + if economy == null or day <= 0 or period_start < 0 or boundary_minute <= period_start: + return {} + var report := { + &"revenue_front": 0, &"revenue_back": 0, &"back_top_up": 0, + &"rank_tax": 0, &"rank_tax_paid": 0, &"loan_interest": 0, &"loan_interest_paid": 0, + } + for entry: LedgerEntry in economy.get_entries(): + if entry.absolute_minute >= period_start and entry.absolute_minute < boundary_minute \ + and entry.category == &"SERVICE_CASH_SWEEP": + if entry.debit_account == FinancePolicy.BACK_CASH: + report[&"revenue_back"] = int(report[&"revenue_back"]) + entry.amount + elif entry.debit_account == PLAYER_CASH_ACCOUNT: + report[&"revenue_front"] = int(report[&"revenue_front"]) + entry.amount + var keyed := { + &"back_top_up": StringName("finance.back_top_up.day.%08d" % day), + &"rank_tax": StringName("finance.rank_tax.day.%08d" % day), + &"loan_interest": StringName("finance.loan_interest.day.%08d" % day), + } + var postings := false + for field: StringName in keyed: + var transaction := economy.get_transaction_id_for_idempotency_key(keyed[field]) + if transaction.is_empty(): + continue + postings = true + for entry: LedgerEntry in economy.get_transaction_entries(transaction): + if field == &"back_top_up" and entry.category == FinancePolicy.TOP_UP_CATEGORY: + report[field] = int(report[field]) + entry.amount + elif field != &"back_top_up" and entry.category in [FinancePolicy.RANK_TAX_CATEGORY, FinancePolicy.LOAN_INTEREST_CATEGORY]: + report[field] = int(report[field]) + entry.amount + elif field != &"back_top_up" and entry.category in [FinancePolicy.RANK_TAX_PAYMENT_CATEGORY, FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY]: + report[StringName(String(field) + "_paid")] = int(report[StringName(String(field) + "_paid")]) + entry.amount + else: + return {} + report[&"rank_tax_arrears"] = _payable_arrears_through(economy, FinancePolicy.RANK_TAX_PAYABLE, boundary_minute) + report[&"loan_interest_arrears"] = _payable_arrears_through(economy, FinancePolicy.LOAN_INTEREST_PAYABLE, boundary_minute) + report[&"has_finance_postings"] = postings + return report + + +func _payable_arrears_through(economy: EconomyService, payable: StringName, minute: int) -> int: + var arrears := 0 + for entry: LedgerEntry in economy.get_entries(): + if entry.absolute_minute > minute: + continue + if entry.credit_account == payable: + arrears += entry.amount + elif entry.debit_account == payable: + arrears -= entry.amount + return arrears + + +func _finance_arrears_through(economy: EconomyService, minute: int) -> int: + return _payable_arrears_through(economy, FinancePolicy.RANK_TAX_PAYABLE, minute) \ + + _payable_arrears_through(economy, FinancePolicy.LOAN_INTEREST_PAYABLE, minute) + + func _loan_principal_ledger_is_valid(economy: EconomyService) -> bool: if economy == null: return false @@ -3697,6 +3887,8 @@ func _operations_expected_category_totals( &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", + FinancePolicy.TOP_UP_CATEGORY, FinancePolicy.RANK_TAX_PAYMENT_CATEGORY, + FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY, ] var mapping := { &"SERVICE_SALE": &"revenue", &"REFUND": &"refunds", @@ -3707,6 +3899,8 @@ func _operations_expected_category_totals( &"EVENT_COST_COMMITTED": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods", &"MAINTENANCE_ACCRUAL": &"maintenance", + FinancePolicy.RANK_TAX_CATEGORY: &"taxes_and_interest", + FinancePolicy.LOAN_INTEREST_CATEGORY: &"taxes_and_interest", &"LOAN_PRINCIPAL_BORROWED": &"financing_principal_inflows", &"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows", } @@ -5254,6 +5448,7 @@ func _initialize_economy() -> void: facility_upkeep = FacilityUpkeepAccrual.new() upkeep_settlement = FacilityUpkeepSettlement.new() FacilityUpkeepSettlement.prepare_accounts(economy_service) + FinancePolicy.prepare_accounts(economy_service) facility_upkeep.start_at(_clock.absolute_minute) mana_reserve = ManaReserveCycle.new() mana_reserve.start_at(_clock.absolute_minute) @@ -11960,6 +12155,9 @@ func _apply_service_transaction( if service.price_snapshot > 0: var payer_account := _guest_wallet_account(request.guest_ids[0]) var revenue_account := _facility_revenue_account(facility.instance_id) + var sale_definition := definition_registry.get_facility(facility.definition_id) + var sale_fund := FinancePolicy.BACK_CASH \ + if sale_definition != null and FinancePolicy.is_back_fund_service(sale_definition.tags) else PLAYER_CASH_ACCOUNT if not economy_service.has_account(payer_account) or not economy_service.has_account(revenue_account): inventory_service.restore_snapshot(inventory_before) return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "A service settlement account is missing.") @@ -11969,7 +12167,7 @@ func _apply_service_transaction( service.price_snapshot, &"SERVICE_SALE", facility.instance_id ), LedgerEntry.new().configure( - &"", absolute_minute, PLAYER_CASH_ACCOUNT, revenue_account, + &"", absolute_minute, sale_fund, revenue_account, service.price_snapshot, &"SERVICE_CASH_SWEEP", facility.instance_id ), ] diff --git a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json index 4947473..a20ce0e 100644 --- a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json +++ b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json @@ -3925,6 +3925,39 @@ "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])" } ] + }, + { + "test": "tests/economy/test_p05_funds_runtime.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "38a90af176e88e83041af7b20324f1383f9f38cdf05fa452c6cf75b8e3bf5b95", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md", + "scope": "new P05-02 suite marker; native execution pending" + } + }, + { + "test": "tests/economy/test_p05_funds_restart_1_write.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "3b28e94f1a1f577d74c5322d532b32854339dd4731532ac0b21b4fbc7ffb12f3", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md", + "scope": "new P05-02 F6 restart writer marker; native execution pending" + } + }, + { + "test": "tests/economy/test_p05_funds_restart_2_read.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "1b5bbf0f5c58df39ec28cc867c18a1a14d293fd36459343a118a3e97bc31d33a", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md", + "scope": "new P05-02 F6 restart reader marker; native execution pending" + } } ], "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting", diff --git a/docs/plans/original-completion-2026-10-02/execution/p05-02/EXPECTATION_CHANGES.json b/docs/plans/original-completion-2026-10-02/execution/p05-02/EXPECTATION_CHANGES.json new file mode 100644 index 0000000..e8f0b52 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p05-02/EXPECTATION_CHANGES.json @@ -0,0 +1,19 @@ +{ + "schema": "p05-02-expectation-changes-v1", + "rule": "SPEC §4 F8: an existing test expectation changes only where the difference is explained by A09, A10, A11 or A12 alone", + "changes": [ + { + "test": "tests/business/test_required_kitchen.gd", + "function": "_approved_runtime_migrations", + "decision": "A12", + "before": "a legacy v14 save loaded back equals the written state plus the approved mana_reserve and elevators migrations", + "after": "the same, plus the five empty fund accounts A12 creates on load (BACK_CASH and RANK_TAX_EXPENSE and LOAN_INTEREST_EXPENSE non-negative; RANK_TAX_PAYABLE and LOAN_INTEREST_PAYABLE allowing negative; none system); every other economy field must stay byte-identical (checked)", + "results_local_diagnostic": { + "base_dda3eed": "163 checks, 0 failures", + "draft_before_change": "163 checks, 2 failures (legacy migration control; whole typed loaded state)", + "draft_after_change": "163 checks, 0 failures" + } + } + ], + "not_changed_by_this_unit": "the other suites that end nonzero in a direct headless run (Main/audio teardown asks for the guarded runner proof; the export pack test needs a built pack; tests/s0/test_kernel_and_save.gd times out at 600 s) end the same way on the unchanged base dda3eed" +} diff --git a/docs/plans/original-completion-2026-10-02/execution/p05-02/F6_F7_IMPLEMENTATION.md b/docs/plans/original-completion-2026-10-02/execution/p05-02/F6_F7_IMPLEMENTATION.md new file mode 100644 index 0000000..23e83d4 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p05-02/F6_F7_IMPLEMENTATION.md @@ -0,0 +1,18 @@ +# P05-02 の F6(別の process での再開)と F7(通常の操作で開く帳簿)の作り + +SPEC(`SPEC.md` §6)の roster 8本は変えない。F6 は SPEC §6 のとおり `tools/run_p05_funds_tests.py` と `tests/**` の範囲で、書き手と読み手の2つの段として roster の後に足した。F7 は SPEC §6 のとおり新しい suite(`tests/economy/test_p05_funds_runtime.gd`)の中で確かめ、帳簿を Main の通常の操作で開く形にした。 + +## F6: 別の process での再開 + +- 書き手 `tests/economy/test_p05_funds_restart_1_write.gd` と読み手 `tests/economy/test_p05_funds_restart_2_read.gd`。共通の fixture と日の筋書きは `tests/helpers/p05_funds_restart_fixture.gd`(新しい suite と同じ帳簿の fixture と 30 分ごとの境界の進め方)。 +- 日の筋書き: 1日目に借入 1000・裏の売上 700(税 70・利息 10)。2日目に経費で表金が 5 になり、利息 10 のうち 5 を裏金から補填(A09)。3日目に表の売上 50(税 5・利息 10)。最後は表金 35・裏金 695。 +- 書き手は、保存しない通しの実行と、2日目の途中(2400)・2日目の 00:00 の締めの直前(2850)・直後(2880)に通常のセーブファイル(`save_to_path`)を書く実行をする。セーブの包み(`manifest.json`・`world.bin`)の bytes を、通しの実行の帳簿の SHA-256 と日次の締めの値と一緒に、宣言した生成ファイル `res://artifacts/p05-02/restart-handoff.json` に書き、`P05_RESTART_WRITER pid=<書き手の PID> handoff_sha256=` を1行だけ出す。 +- 読み手は次の段の別の engine の process で、handoff を読み(書き手の PID が自分と違うこと、書き手の source の SHA が今のものと同じことを確かめる)、`P05_RESTART_READER pid=<読み手の PID> writer_pid=<書き手の PID> handoff_sha256=` を1行だけ出す。自分の profile に包みの bytes をそのまま置き(SHA を確かめる)、新しい Runtime で `load_from_path` で読み、残りの筋書きを進める。3つの保存点のどれから再開しても、帳簿の bytes が通しの実行(自分の process と書き手の process の両方)と同じで、日次の締め・資金の報告・滞納が同じで、税と利息の計上が5回、補填が1回だけ(二重にならない)であることを確かめる。`world.bin` を1 byte 変えた包みは読込を拒否され、Gold も時刻も変わらない。 +- 段をまたいで bytes を保つ共有の仕組みは、`tools/regression/suite-policy.json` の `fixed_generated_report_routes` だけである(共有の `declared_reports` が、後の全ての段の前に、それまでの生成ファイルの bytes の一致を確かめる)。そこで書き手の1つだけの生成ファイルとして handoff を宣言した。狭い roster の8本には生成ファイルはなく、`tests/mvp/test_runtime_upkeep.gd` を roster に入れない扱い(SPEC §6)は変えていない。 +- `tools/run_p05_funds_tests.py` の照合で足したこと: 段は import・parse・roster 8・再開 2 の 12。書き手の段の生成ファイルは handoff の1つだけで、残した stage の bytes・書き手の PID・書き手の source の SHA と一致する。書き手が出した行の PID と SHA、handoff に書かれた PID が書き手の段の実際の PID と一致する。読み手が出した行の PID が読み手の段の実際の PID、書き手の PID が書き手の段の実際の PID と一致し(両者は違う)、SHA が handoff と一致する。他の段は生成ファイルを持たない。残した stage は source と handoff だけ。外の報告の経路(`outside_routes`)は使わない。 +- 全体の roster(`tools/run_all_tests.py`)は名前の順に走るので、書き手が読み手より先に走る(`tools/run_p05_funds_tests.py` が起動の前に確かめる)。全体の照合(`tools/regression/review_protocol.py`)は宣言した生成ファイルを同じ規則で確かめる。読み手だけを単独で直接走らせると、handoff がないので失敗する(書き手を先に走らせる必要がある)。 + +## F7: 通常の操作で開く帳簿 + +- 新しい suite の最後に、ゲームの本物の `SimulationRuntime`・`GameClock`・`GameState` と `scenes/main/main.tscn` を使い、1日目に借入 1000・表の売上 300・裏の売上 500 を置いて、通常の時計で 00:00 の締めまで進める。Main の左の「帳簿」のボタン(`navigation_buttons[&"ledger"]`)を押して帳簿を開き、表金 6210・裏金 500、本日(00:00 に始まる2日目)の税 80・利息 10 の支払い、1日目の締めの「うち表の売上 300G / 裏の売上 500G」「世界樹ランク税 80G(支払 80G)/ 利息 10G(支払 10G)」「裏金から表金へ補填 0G / 未払い 税 0G・利息 0G」を読む。 +- Main を使うので、新しい suite の方針を通常の Main の音声の後始末の証明つき(`ordinary_cleanup_prepared`、札 `single`)にした(`tests/ui/test_upkeep_closed_ui.gd` と同じ)。最後に `tests/helpers/ui_audio_fixture_cleanup.gd` で Main を片付け、その証明を guarded の runner が照合する。 diff --git a/scripts/economy/facility_upkeep_settlement.gd b/scripts/economy/facility_upkeep_settlement.gd index cd89ec1..e3909f7 100644 --- a/scripts/economy/facility_upkeep_settlement.gd +++ b/scripts/economy/facility_upkeep_settlement.gd @@ -65,6 +65,33 @@ func settle(period: Dictionary, economy: EconomyService) -> Dictionary: return {&"ok": true, &"replayed": false, &"receipt": receipt.duplicate(true), &"transaction_id": transaction_id} +## Whole Gold that settling these closed periods would owe (arrears included), +## planned with no cash; -1 if a period is invalid or already settled. Nothing changes. +func preview_debt(periods: Array) -> int: + var arrears := _arrears.duplicate() + var fractions := _fractions.duplicate() + var last_end := int(_receipts[-1].period.period_end) if not _receipts.is_empty() else -1 + var candidate := FacilityUpkeepSettlement.new() + for raw: Variant in periods: + if not raw is Dictionary: + return -1 + var canonical := _parse_period(raw) + if canonical.is_empty() or (last_end >= 0 and canonical.period_start != last_end): + return -1 + candidate._arrears = arrears + candidate._fractions = fractions + var plan := candidate._plan(canonical, 0) + if not plan[&"ok"]: + return -1 + arrears = plan.arrears + fractions = plan.fractions + last_end = canonical.period_end + var debt := 0 + for amount: int in arrears.values(): + debt += amount + return debt + + func get_arrears_through(minute: int) -> int: var low := 0 var high := _receipts.size() diff --git a/scripts/economy/finance_policy.gd b/scripts/economy/finance_policy.gd new file mode 100644 index 0000000..20ab359 --- /dev/null +++ b/scripts/economy/finance_policy.gd @@ -0,0 +1,86 @@ +class_name FinancePolicy +extends RefCounted + +## Front (表金) and hidden (裏金) funds, the World Tree rank tax and dark bank interest. +## Values are the user-approved initial test values (decisions A09-A12, 2026-10-23). +## The front fund keeps the existing PLAYER_CASH account; saves made before the split +## carry no hidden fund account and are read with every Gold as front funds (A12). + +const FRONT_CASH: StringName = &"PLAYER_CASH" +const BACK_CASH: StringName = &"BACK_CASH" +const RANK_TAX_EXPENSE: StringName = &"RANK_TAX_EXPENSE" +const RANK_TAX_PAYABLE: StringName = &"RANK_TAX_PAYABLE" +const LOAN_INTEREST_EXPENSE: StringName = &"LOAN_INTEREST_EXPENSE" +const LOAN_INTEREST_PAYABLE: StringName = &"LOAN_INTEREST_PAYABLE" +const LOAN_LIABILITY_PREFIX := "LOAN_LIABILITY:" + +## A10: 10% of the closed day's sales (front + hidden), whole Gold rounded down. +const RANK_TAX_PERCENT := 10 +## A11: 1% of the outstanding principal per day, whole Gold rounded down. +const LOAN_INTEREST_PERCENT_PER_DAY := 1 + +## Services of facilities with one of these tags earn hidden funds (原本: 成人施設、 +## カジノ、脅迫金、ユニット売却は裏の収益). Every other service earns front funds. +const BACK_FUND_TAGS: Array[StringName] = [&"restricted_service", &"adult_only"] + +## Ledger categories. Accruals are expenses of the closed day; payments, top-ups and +## the payable balances are not income or expense. +const TOP_UP_CATEGORY: StringName = &"BACK_FUND_TOP_UP" +const RANK_TAX_CATEGORY: StringName = &"RANK_TAX" +const RANK_TAX_PAYMENT_CATEGORY: StringName = &"RANK_TAX_PAYMENT" +const LOAN_INTEREST_CATEGORY: StringName = &"LOAN_INTEREST" +const LOAN_INTEREST_PAYMENT_CATEGORY: StringName = &"LOAN_INTEREST_PAYMENT" +const NON_PROFIT_CATEGORIES: Array[StringName] = [TOP_UP_CATEGORY, RANK_TAX_PAYMENT_CATEGORY, LOAN_INTEREST_PAYMENT_CATEGORY] + + +static func rank_tax(sales: int) -> int: + return maxi(sales, 0) / 100 * RANK_TAX_PERCENT + maxi(sales, 0) % 100 * RANK_TAX_PERCENT / 100 + + +static func loan_interest(outstanding_principal: int) -> int: + return maxi(outstanding_principal, 0) / 100 * LOAN_INTEREST_PERCENT_PER_DAY \ + + maxi(outstanding_principal, 0) % 100 * LOAN_INTEREST_PERCENT_PER_DAY / 100 + + +static func is_back_fund_service(tags: Array) -> bool: + for tag: StringName in BACK_FUND_TAGS: + if tag in tags: + return true + return false + + +## Accounts of the split funds. A save without BACK_CASH predates the split and must +## not hold any of the other accounts either; they are then created empty (A12). +## Any other partial or incompatible set is rejected. +static func prepare_accounts(economy: EconomyService) -> bool: + if economy == null or not economy.has_account(FRONT_CASH) or economy.account_allows_negative(FRONT_CASH): + return false + var expected := { + BACK_CASH: false, RANK_TAX_EXPENSE: false, LOAN_INTEREST_EXPENSE: false, + RANK_TAX_PAYABLE: true, LOAN_INTEREST_PAYABLE: true, + } + if not economy.has_account(BACK_CASH): + for account_id: StringName in expected: + if economy.has_account(account_id): + return false + for account_id: StringName in expected: + if not economy.register_account(account_id, bool(expected[account_id])): + return false + return true + for account_id: StringName in expected: + if not economy.has_account(account_id) \ + or economy.account_allows_negative(account_id) != bool(expected[account_id]): + return false + return true + + +static func outstanding_principal(economy: EconomyService) -> int: + var total := 0 + for account: Dictionary in economy.get_accounts(): + if String(account[&"account_id"]).begins_with(LOAN_LIABILITY_PREFIX): + total += -int(account[&"balance"]) + return total + + +static func available_funds(economy: EconomyService) -> int: + return economy.get_balance(FRONT_CASH) + economy.get_balance(BACK_CASH) diff --git a/scripts/economy/finance_policy.gd.uid b/scripts/economy/finance_policy.gd.uid new file mode 100644 index 0000000..0001916 --- /dev/null +++ b/scripts/economy/finance_policy.gd.uid @@ -0,0 +1 @@ +uid://l14tq0avw0ux diff --git a/scripts/supply/supply_management_service.gd b/scripts/supply/supply_management_service.gd index 3ee3de3..c195bb4 100644 --- a/scripts/supply/supply_management_service.gd +++ b/scripts/supply/supply_management_service.gd @@ -18,7 +18,8 @@ func preview_purchase(runtime: Variant, warehouse_id: StringName, item_id: Strin or not bool(item.get(&"purchasable", false)) or quantity <= 0 or quantity > 10000: return quote var frozen := PurchaseQuoteV2.make(item_id, quantity, int(item[&"unit_price"])) if frozen.is_empty(): return quote - var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH") + # A09: purchases may use the hidden fund for what the front fund cannot pay. + var cash: int = FinancePolicy.available_funds(runtime.economy_service) var space: int = maxi(SupplyCatalog.storage_capacity(facility.definition_id) - SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id) - _incoming_weight(runtime, facility.local_inventory_id), 0) @warning_ignore("integer_division") var slots := space / int(item[&"unit_weight"]) @@ -40,7 +41,7 @@ func preview_purchase(runtime: Variant, warehouse_id: StringName, item_id: Strin func _preview_purchase_v1(runtime: Variant, warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary: - var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH") + var cash: int = FinancePolicy.available_funds(runtime.economy_service) var item := SupplyCatalog.get_item(item_id) var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id) var quote := {&"ok": false, &"reason": &"", &"total_cost": 0, &"cash_after": cash, &"quantity_after": 0, &"max_quantity": 0} @@ -99,6 +100,13 @@ func _purchase_paid(runtime: Variant, warehouse_id: StringName, item_id: StringN var memo: StringName = SupplyAutoPurchasePlans.receipt_memo(item_id, quantity, int(item[&"unit_price"]), int(quote[&"total_cost"])) if record_auto_attempt else &"supply_purchase" if quote.has(&"purchase_quote"): memo = PurchaseQuoteV2.receipt_memo(quote[&"purchase_quote"]) + # A09: the hidden fund covers what the front fund cannot pay, in its own posting + # before the single-entry purchase (its receipt shape is unchanged). + var top_up: CommandResult = runtime.top_up_front_fund(int(quote[&"total_cost"]), minute, StringName("finance.back_top_up.purchase." + String(ledger_key))) + if not top_up.is_success(): + runtime.inventory_service.restore_snapshot(inventory_before) + runtime.economy_service.restore_snapshot(economy_before) + return _purchase_failure(command_id, &"PAYMENT_FAILED") var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, ledger_key, memo, &"", false) if not paid.is_success(): runtime.inventory_service.restore_snapshot(inventory_before) @@ -114,7 +122,8 @@ func _purchase_paid(runtime: Variant, warehouse_id: StringName, item_id: StringN runtime.inventory_service.restore_snapshot(inventory_before) runtime.economy_service.restore_snapshot(economy_before) return _purchase_failure(command_id, &"PAYMENT_FAILED") - if not runtime.economy_service.commit_transaction_notification(StringName(paid.details[&"transaction_id"])): + if not runtime.economy_service.commit_transaction_notification(StringName(paid.details[&"transaction_id"])) \ + or (int(top_up.details.get(&"amount", 0)) > 0 and not runtime.economy_service.commit_transaction_notification(StringName(top_up.details[&"transaction_id"]))): runtime.inventory_service.restore_snapshot(inventory_before) runtime.economy_service.restore_snapshot(economy_before) if record_auto_attempt: @@ -274,7 +283,7 @@ func get_report(runtime: Variant) -> Dictionary: row[&"reason_codes"].append(&"NO_INPUT") destinations.append(row) var bridge_report: Dictionary = runtime.supply_logistics.get_report() - return {&"cash": runtime.economy_service.get_balance(&"PLAYER_CASH"), &"catalog": SupplyCatalog.get_purchase_items(), &"warehouses": warehouses, &"kitchens": kitchens, &"destinations": destinations, &"deliveries": bridge_report.get(&"deliveries", [])} + return {&"cash": runtime.economy_service.get_balance(&"PLAYER_CASH"), &"back_cash": runtime.economy_service.get_balance(FinancePolicy.BACK_CASH), &"catalog": SupplyCatalog.get_purchase_items(), &"warehouses": warehouses, &"kitchens": kitchens, &"destinations": destinations, &"deliveries": bridge_report.get(&"deliveries", [])} func ensure_deliveries(runtime: Variant, minute: int) -> void: diff --git a/scripts/ui/operating_ledger_panel.gd b/scripts/ui/operating_ledger_panel.gd index bc4ac55..3f23761 100644 --- a/scripts/ui/operating_ledger_panel.gd +++ b/scripts/ui/operating_ledger_panel.gd @@ -76,9 +76,17 @@ func get_report() -> Dictionary: var construction_outflow := 0 var wage_outflow := 0 var upkeep_outflow := 0 + var back_inflow := 0 + var back_top_up := 0 + var rank_tax_outflow := 0 + var loan_interest_outflow := 0 for entry: LedgerEntry in _runtime.economy_service.get_entries(): if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION": continue + if entry.debit_account == FinancePolicy.BACK_CASH: + back_inflow += entry.amount + if entry.credit_account == FinancePolicy.BACK_CASH and entry.category == FinancePolicy.TOP_UP_CATEGORY: + back_top_up += entry.amount if entry.debit_account == &"PLAYER_CASH": inflow += entry.amount if entry.credit_account == &"PLAYER_CASH": @@ -89,6 +97,10 @@ func get_report() -> Dictionary: upkeep_outflow += entry.amount elif entry.category == &"WAGE": wage_outflow += entry.amount + elif entry.category == FinancePolicy.RANK_TAX_PAYMENT_CATEGORY: + rank_tax_outflow += entry.amount + elif entry.category == FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY: + loan_interest_outflow += entry.amount var summaries: Array[Dictionary] = [] if _runtime.operations_coordinator != null: summaries = _runtime.operations_coordinator.get_day_summaries() @@ -105,6 +117,11 @@ func get_report() -> Dictionary: &"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow, &"upkeep_cash_out": upkeep_outflow, &"upkeep_arrears": _runtime.upkeep_settlement.get_arrears_total(), + &"back_cash": _runtime.economy_service.get_balance(FinancePolicy.BACK_CASH), + &"back_cash_in": back_inflow, &"back_top_up": back_top_up, + &"rank_tax_cash_out": rank_tax_outflow, &"loan_interest_cash_out": loan_interest_outflow, + &"finance_arrears": -_runtime.economy_service.get_balance(FinancePolicy.RANK_TAX_PAYABLE) + - _runtime.economy_service.get_balance(FinancePolicy.LOAN_INTEREST_PAYABLE), &"closed_days": summaries, &"selected_close": selected.duplicate(true), } @@ -117,6 +134,8 @@ func refresh() -> void: _refresh_repayment_controls() var report := get_report() _cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]] + # 表金は上の手持ち。裏金は裏のサービスの売上で、表金で払えない分だけを補う(A09)。 + _cash_label.text += "\n裏金 %dG(本日の入金 +%dG / 表金へ補填 −%dG)\n本日の税 %dG / 利息 %dG(未払い %dG)" % [report[&"back_cash"], report[&"back_cash_in"], report[&"back_top_up"], report[&"rank_tax_cash_out"], report[&"loan_interest_cash_out"], report[&"finance_arrears"]] var summary: Dictionary = report[&"selected_close"] if summary.is_empty(): if not _day_picker.disabled or _day_picker.item_count == 0: @@ -143,6 +162,9 @@ func refresh() -> void: for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]: other += int(totals.get(key, 0)) _closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n日次利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"] + var funds: Dictionary = (summary.get(&"settlement", {}) as Dictionary).get(&"funds", {}) + if not funds.is_empty(): + _closed_label.text += "\nうち表の売上 %dG / 裏の売上 %dG\n世界樹ランク税 %dG(支払 %dG)/ 利息 %dG(支払 %dG)\n裏金から表金へ補填 %dG / 未払い 税 %dG・利息 %dG" % [int(funds.get(&"revenue_front", 0)), int(funds.get(&"revenue_back", 0)), int(funds.get(&"rank_tax", 0)), int(funds.get(&"rank_tax_paid", 0)), int(funds.get(&"loan_interest", 0)), int(funds.get(&"loan_interest_paid", 0)), int(funds.get(&"back_top_up", 0)), int(funds.get(&"rank_tax_arrears", 0)), int(funds.get(&"loan_interest_arrears", 0))] if totals.has(&"financing_principal_inflows") or totals.has(&"financing_principal_outflows"): _closed_label.text += "\n借入元本の入金 %dG(売上外) / 元本返済 %dG(決算経費)" % [int(totals.get(&"financing_principal_inflows", 0)), int(totals.get(&"financing_principal_outflows", 0))] if _runtime.operations_coordinator.is_legacy_principal_profit_history(int(summary[&"day"])): diff --git a/tests/business/test_required_kitchen.gd b/tests/business/test_required_kitchen.gd index 9d59eb9..93a34ae 100644 --- a/tests/business/test_required_kitchen.gd +++ b/tests/business/test_required_kitchen.gd @@ -300,6 +300,28 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D old[&"elevators"] = raised fields.append("elevators_v1_to_v2") proof["elevators"] = {"only_version_changed": same_otherwise} + # P05-02 A12: a save from before the funds were split gains the five empty fund + # accounts on load; every other economy field stays byte-identical. + var has_back_cash := func(economy: Variant) -> bool: + if not economy is Dictionary: + return false + for account: Variant in (economy as Dictionary).get(&"accounts", []): + if account is Dictionary and String((account as Dictionary).get(&"account_id", "")) == "BACK_CASH": + return true + return false + if not has_back_cash.call(old.get(&"economy")) and has_back_cash.call(now.get(&"economy")): + var added := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true} + var economy: Dictionary = (old[&"economy"] as Dictionary).duplicate(true) + var accounts: Array = (economy[&"accounts"] as Array).duplicate(true) + for account_id: String in added: + accounts.append({&"account_id": account_id, &"allow_negative": added[account_id], &"system": false}) + accounts.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"account_id"]) < String(right[&"account_id"])) + economy[&"accounts"] = accounts + var only_accounts_added: bool = var_to_bytes(economy) == var_to_bytes(now[&"economy"]) + ok = ok and only_accounts_added + old[&"economy"] = economy + fields.append("economy_A12_empty_fund_accounts") + proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added} if not fields.is_empty(): # Saves keep the Runtime's own key order, so the mapped tree follows it. ok = ok and old.size() == now.size() diff --git a/tests/economy/test_p05_funds_restart_1_write.gd b/tests/economy/test_p05_funds_restart_1_write.gd new file mode 100644 index 0000000..09d8e3a --- /dev/null +++ b/tests/economy/test_p05_funds_restart_1_write.gd @@ -0,0 +1,77 @@ +extends SceneTree + +## P05-02 F6, writer phase. Plays the day script, writes ordinary save files at +## mid-day, just before the day 2 00:00 close and just after it, and hands the +## exact save packages to the reader suite (test_p05_funds_restart_2_read.gd), +## which runs as a separate engine process. The handoff is the declared generated +## report res://artifacts/p05-02/restart-handoff.json +## (tools/regression/suite-policy.json fixed_generated_report_routes). + +const Fixture := preload("res://tests/helpers/p05_funds_restart_fixture.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P05-02: restart writer") + var fixtures := Fixture.new(_expect) + var straight := fixtures.new_fixture() + fixtures.play(straight, 0, Fixture.LAST_MINUTE, Callable()) + var straight_runtime: Variant = straight[&"runtime"] + _expect(straight_runtime.economy_service.get_balance(Fixture.FRONT) == 35 and straight_runtime.economy_service.get_balance(Fixture.BACK) == 695, "uninterrupted day script ends with front 35 and hidden 695") + _expect(fixtures.category_count(straight_runtime, [FinancePolicy.TOP_UP_CATEGORY]) == 1, "uninterrupted day script tops up once (day 2)") + var saving := fixtures.new_fixture() + var saved_paths: Dictionary = {} + fixtures.play(saving, 0, Fixture.LAST_MINUTE, func(minute: int) -> void: + if minute in Fixture.SAVE_MINUTES: + var path := Fixture.WRITER_SAVE % minute + _expect(bool(saving[&"runtime"].save_to_path(path).get(&"ok", false)), "save file at minute %d" % minute) + saved_paths[minute] = path) + _expect(fixtures.economy_bytes(saving[&"runtime"]) == fixtures.economy_bytes(straight_runtime), "writing the save files leaves the ledger as without saving") + var saves: Array = [] + for minute: int in Fixture.SAVE_MINUTES: + var directory := ProjectSettings.globalize_path(str(saved_paths.get(minute, Fixture.WRITER_SAVE % minute))) + _expect(DirAccess.dir_exists_absolute(directory) and DirAccess.get_directories_at(directory).is_empty(), "save package at minute %d holds only files" % minute) + var files := {} + for name: String in DirAccess.get_files_at(directory): + var bytes := FileAccess.get_file_as_bytes(directory.path_join(name)) + files[name] = {"sha256": Fixture.sha256(bytes), "bytes": bytes.size(), "base64": Marshalls.raw_to_base64(bytes)} + _expect(files.has("manifest.json") and files.has("world.bin"), "save package at minute %d has its manifest and world" % minute) + saves.append({"minute": minute, "files": files}) + var handoff := { + "schema": Fixture.HANDOFF_SCHEMA, + "writer_pid": OS.get_process_id(), + "writer_source_sha256": FileAccess.get_sha256(Fixture.WRITER_SOURCE), + "seed": Fixture.SEED, + "last_minute": Fixture.LAST_MINUTE, + "straight_economy_sha256": Fixture.sha256(fixtures.economy_bytes(straight_runtime)), + "straight_days_base64": Marshalls.raw_to_base64(var_to_bytes(fixtures.days_value(straight_runtime))), + "saves": saves, + } + DirAccess.make_dir_recursive_absolute(Fixture.HANDOFF_PATH.get_base_dir()) + var file := FileAccess.open(Fixture.HANDOFF_PATH, FileAccess.WRITE) + _expect(file != null, "handoff file opens for writing") + if file != null: + file.store_string(JSON.stringify(handoff, "\t") + "\n") + file.close() + print("P05_RESTART_WRITER pid=%d handoff_sha256=%s" % [OS.get_process_id(), FileAccess.get_sha256(Fixture.HANDOFF_PATH)]) + fixtures.free_fixture(straight) + fixtures.free_fixture(saving) + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/economy/test_p05_funds_restart_1_write.gd.uid b/tests/economy/test_p05_funds_restart_1_write.gd.uid new file mode 100644 index 0000000..72df6cd --- /dev/null +++ b/tests/economy/test_p05_funds_restart_1_write.gd.uid @@ -0,0 +1 @@ +uid://bsck1wvucocy3 diff --git a/tests/economy/test_p05_funds_restart_2_read.gd b/tests/economy/test_p05_funds_restart_2_read.gd new file mode 100644 index 0000000..b7d423b --- /dev/null +++ b/tests/economy/test_p05_funds_restart_2_read.gd @@ -0,0 +1,119 @@ +extends SceneTree + +## P05-02 F6, reader phase. Runs after the writer suite +## (test_p05_funds_restart_1_write.gd) as a separate engine process, loads each +## save package the writer wrote, continues the day script and requires the same +## ledger and daily closes as the uninterrupted run: no double rank tax, interest +## or top-up across mid-day, pre-00:00 and post-00:00 restarts. A changed package +## is refused. + +const Fixture := preload("res://tests/helpers/p05_funds_restart_fixture.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P05-02: restart reader") + var handoff := _read_handoff() + if not handoff.is_empty(): + _check_restarts(handoff) + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _read_handoff() -> Dictionary: + _expect(FileAccess.file_exists(Fixture.HANDOFF_PATH), "the writer phase left its handoff (run test_p05_funds_restart_1_write.gd first)") + if not FileAccess.file_exists(Fixture.HANDOFF_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(Fixture.HANDOFF_PATH)) + _expect(parsed is Dictionary and str((parsed as Dictionary).get("schema", "")) == Fixture.HANDOFF_SCHEMA, "handoff schema") + if not parsed is Dictionary or str((parsed as Dictionary).get("schema", "")) != Fixture.HANDOFF_SCHEMA: + return {} + var handoff: Dictionary = parsed + var writer_pid := int(handoff.get("writer_pid", 0)) + _expect(writer_pid > 0 and writer_pid != OS.get_process_id(), "a separate engine process wrote the saves") + _expect(str(handoff.get("writer_source_sha256", "")) == FileAccess.get_sha256(Fixture.WRITER_SOURCE), "the handoff comes from the current writer suite") + _expect(int(handoff.get("seed", -1)) == Fixture.SEED and int(handoff.get("last_minute", -1)) == Fixture.LAST_MINUTE, "same fixture and day script") + print("P05_RESTART_READER pid=%d writer_pid=%d handoff_sha256=%s" % [OS.get_process_id(), writer_pid, FileAccess.get_sha256(Fixture.HANDOFF_PATH)]) + return handoff + + +func _check_restarts(handoff: Dictionary) -> void: + var fixtures := Fixture.new(_expect) + var straight := fixtures.new_fixture() + fixtures.play(straight, 0, Fixture.LAST_MINUTE, Callable()) + var straight_runtime: Variant = straight[&"runtime"] + var straight_economy := fixtures.economy_bytes(straight_runtime) + _expect(Fixture.sha256(straight_economy) == str(handoff.get("straight_economy_sha256", "")), "the uninterrupted day script gives the same ledger in both processes") + var writer_days: Variant = bytes_to_var(Marshalls.base64_to_raw(str(handoff.get("straight_days_base64", "")))) + _expect(fixtures.days_equal(straight_runtime, writer_days), "the uninterrupted day script gives the same daily closes in both processes") + var saves: Array = handoff.get("saves", []) + var minutes: Array[int] = [] + for save: Variant in saves: + minutes.append(int((save as Dictionary).get("minute", -1)) if save is Dictionary else -1) + _expect(minutes == Fixture.SAVE_MINUTES, "the writer saved mid-day, before and after the day 2 00:00 close") + for save: Variant in saves: + if not save is Dictionary: + continue + var minute := int((save as Dictionary).get("minute", -1)) + var path := Fixture.READER_SAVE % minute + if not _materialize(path, (save as Dictionary).get("files", {}), false): + continue + var resumed := fixtures.new_fixture() + var runtime: Variant = resumed[&"runtime"] + _expect(bool(runtime.load_from_path(path).get(&"ok", false)), "this process loads the save written at minute %d" % minute) + _expect(int(resumed[&"clock"].absolute_minute) == minute, "the loaded save resumes at minute %d" % minute) + fixtures.play(resumed, minute, Fixture.LAST_MINUTE, Callable()) + _expect(fixtures.economy_bytes(runtime) == straight_economy, "resuming at minute %d gives exactly the uninterrupted ledger" % minute) + _expect(fixtures.days_equal(runtime, writer_days), "resuming at minute %d gives the same daily closes, fund reports and arrears" % minute) + # Day 1: tax and interest; day 2: interest; day 3: tax and interest. + _expect(fixtures.category_count(runtime, [FinancePolicy.RANK_TAX_CATEGORY, FinancePolicy.LOAN_INTEREST_CATEGORY]) == 5, "minute %d: each tax and interest accrual is posted once" % minute) + _expect(fixtures.category_count(runtime, [FinancePolicy.TOP_UP_CATEGORY]) == 1, "minute %d: the day 2 top-up is posted once" % minute) + _expect(runtime.economy_service.get_balance(Fixture.FRONT) == 35 and runtime.economy_service.get_balance(Fixture.BACK) == 695, "minute %d: front 35 and hidden 695" % minute) + fixtures.free_fixture(resumed) + if not saves.is_empty() and saves[0] is Dictionary and _materialize(Fixture.READER_TAMPERED_SAVE, (saves[0] as Dictionary).get("files", {}), true): + var fixture := fixtures.new_fixture() + var runtime: Variant = fixture[&"runtime"] + var before := fixtures.economy_bytes(runtime) + _expect(not bool(runtime.load_from_path(Fixture.READER_TAMPERED_SAVE).get(&"ok", true)), "a changed save package is refused") + _expect(fixtures.economy_bytes(runtime) == before and int(fixture[&"clock"].absolute_minute) == 360, "a refused load changes no Gold and no time") + fixtures.free_fixture(fixture) + fixtures.free_fixture(straight) + + +# Writes the writer's exact package bytes into this process's own profile. +func _materialize(path: String, files: Variant, change_world: bool) -> bool: + var directory := ProjectSettings.globalize_path(path) + _expect(files is Dictionary and (files as Dictionary).has("manifest.json") and (files as Dictionary).has("world.bin"), "handoff package %s is complete" % path) + if not files is Dictionary: + return false + _expect(DirAccess.make_dir_recursive_absolute(directory) == OK, "package directory %s" % path) + for name: String in (files as Dictionary): + var pin: Dictionary = (files as Dictionary)[name] + var bytes := Marshalls.base64_to_raw(str(pin.get("base64", ""))) + _expect(Fixture.sha256(bytes) == str(pin.get("sha256", "")) and bytes.size() == int(pin.get("bytes", -1)), "handoff bytes of %s are exact" % name) + if change_world and name == "world.bin" and not bytes.is_empty(): + bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0xff + var file := FileAccess.open(directory.path_join(name), FileAccess.WRITE) + _expect(file != null, "write %s into %s" % [name, path]) + if file == null: + return false + file.store_buffer(bytes) + file.close() + return true + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/economy/test_p05_funds_restart_2_read.gd.uid b/tests/economy/test_p05_funds_restart_2_read.gd.uid new file mode 100644 index 0000000..9055854 --- /dev/null +++ b/tests/economy/test_p05_funds_restart_2_read.gd.uid @@ -0,0 +1 @@ +uid://c8yrv3udeb87l diff --git a/tests/economy/test_p05_funds_runtime.gd b/tests/economy/test_p05_funds_runtime.gd new file mode 100644 index 0000000..3d9d771 --- /dev/null +++ b/tests/economy/test_p05_funds_runtime.gd @@ -0,0 +1,462 @@ +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. +## Sales, debt and drains are explicit ledger fixtures; the daily closes are the +## production Operations boundaries. 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() + 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) + + +# 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") diff --git a/tests/economy/test_p05_funds_runtime.gd.uid b/tests/economy/test_p05_funds_runtime.gd.uid new file mode 100644 index 0000000..db05fb8 --- /dev/null +++ b/tests/economy/test_p05_funds_runtime.gd.uid @@ -0,0 +1 @@ +uid://2v2xxx0grnx8 diff --git a/tests/helpers/p05_funds_restart_fixture.gd b/tests/helpers/p05_funds_restart_fixture.gd new file mode 100644 index 0000000..095645d --- /dev/null +++ b/tests/helpers/p05_funds_restart_fixture.gd @@ -0,0 +1,161 @@ +extends RefCounted + +## P05-02 F6: the fixture and day script shared by the restart writer +## (tests/economy/test_p05_funds_restart_1_write.gd) and reader +## (tests/economy/test_p05_funds_restart_2_read.gd), which run as separate engine +## processes. Same ledger fixture and accelerated boundary traversal as +## tests/economy/test_p05_funds_runtime.gd. + +const HANDOFF_PATH := "res://artifacts/p05-02/restart-handoff.json" +const HANDOFF_SCHEMA := "p05-02-restart-handoff-v1" +const WRITER_SOURCE := "res://tests/economy/test_p05_funds_restart_1_write.gd" +const WRITER_SAVE := "user://p05_restart_write/minute_%d" +const READER_SAVE := "user://p05_restart_read/minute_%d" +const READER_TAMPERED_SAVE := "user://p05_restart_read/changed" +const SEED := 5025 +# Mid-day 2, just before the day 2 00:00 close (which needs a hidden-fund top-up) +# and just after it. +const SAVE_MINUTES: Array[int] = [2400, 2850, 2880] +const LAST_MINUTE := 4320 + +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" + +var _expect: Callable + + +func _init(expect: Callable) -> void: + _expect = expect + + +func new_fixture() -> 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.call(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.call(bool(runtime.enable_production_operations().get(&"ok", false)), "fixture enables production Operations") + for account: StringName in [FRONT_SALES, BACK_SALES, PAYER]: + runtime.economy_service.register_account(account) + runtime.economy_service.register_account(LOAN, true) + _expect.call(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() + + +## Applies the day script's steps after `after_minute` through `through_minute` +## and calls `on_minute` after each step (the writer saves there). +## Day 1: borrow 1000, hidden sale 700 (tax 70, interest 10). Day 2: a cost leaves +## 5 front funds for the 10 interest, so hidden funds top up 5. Day 3: front sale +## 50 (tax 5, interest 10). Final: front 35, hidden 695. +func play(fixture: Dictionary, after_minute: int, through_minute: int, on_minute: Callable) -> void: + 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", 5915, &"OPERATING_EXPENSE", "d2.cost")], + [2400, Callable()], + [2850, 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]) + if minute <= after_minute or minute > through_minute: + continue + _advance_to(fixture, minute) + if (step[1] as Callable).is_valid(): + (step[1] as Callable).call() + if on_minute.is_valid(): + on_minute.call(minute) + + +func economy_bytes(runtime: Variant) -> PackedByteArray: + return var_to_bytes(runtime.create_snapshot()[&"systems"][&"runtime"][&"economy"]) + + +## Operations history in the transport-safe form the Runtime's own restore +## crosslink compares (StringName ids become String values in saves). +func days_value(runtime: Variant) -> Variant: + return runtime.call("_operations_snapshot_comparison_value", runtime.operations_coordinator.get_day_summaries()) + + +func days_equal(runtime: Variant, expected: Variant) -> bool: + return runtime.call("_strict_variants_equal", expected, days_value(runtime)) + + +func category_count(runtime: Variant, categories: Array) -> int: + var count := 0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if entry.category in categories: + count += 1 + return count + + +static func sha256(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + if not bytes.is_empty(): + context.update(bytes) + return context.finish().hex_encode() + + +# Every Operations boundary is on a 30-minute mark; each step ends on one. +func _advance_to(fixture: Dictionary, minute: int) -> void: + var clock: Variant = fixture[&"clock"] + var runtime: Variant = fixture[&"runtime"] + while int(clock.absolute_minute) < minute: + var step := mini(30 - int(clock.absolute_minute) % 30, minute - int(clock.absolute_minute)) + 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) + + +# 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.call(false, "accelerated fixture cannot skip live elevator movement") + return + transport_snapshot[&"current_minute"] = minute + if not runtime.elevators.restore_snapshot(transport_snapshot): + _expect.call(false, "empty transport clock aligns through validated snapshot restore") + + +func _sale(fixture: Dictionary, revenue_account: StringName, fund: StringName, amount: int, key: String) -> void: + 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"), + ] + _expect.call(runtime.economy_service.post(entries, StringName("p05.sale." + key)).is_success(), "fixture sale " + key) + + +func _move(fixture: Dictionary, from: StringName, to: StringName, amount: int, category: StringName, key: String) -> void: + var runtime: Variant = fixture[&"runtime"] + _expect.call(runtime.economy_service.transfer(from, to, amount, fixture[&"clock"].absolute_minute, category, + &"p05.fixture", StringName("p05.move." + key)).is_success(), "fixture move " + key) diff --git a/tests/helpers/p05_funds_restart_fixture.gd.uid b/tests/helpers/p05_funds_restart_fixture.gd.uid new file mode 100644 index 0000000..ecfa2e8 --- /dev/null +++ b/tests/helpers/p05_funds_restart_fixture.gd.uid @@ -0,0 +1 @@ +uid://m3lik2gqaho2 diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index b0c0704..41cb1a4 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -414,6 +414,29 @@ ], "old_bounded_teardown": false, "new_audio_QA": false + }, + "tests/economy/test_p05_funds_runtime.gd": { + "source_sha256": "38a90af176e88e83041af7b20324f1383f9f38cdf05fa452c6cf75b8e3bf5b95", + "ordinary_cleanup_prepared": true, + "ordinary_control_labels": [ + "single" + ], + "old_bounded_teardown": false, + "new_audio_QA": false + }, + "tests/economy/test_p05_funds_restart_1_write.gd": { + "source_sha256": "3b28e94f1a1f577d74c5322d532b32854339dd4731532ac0b21b4fbc7ffb12f3", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": false, + "new_audio_QA": false + }, + "tests/economy/test_p05_funds_restart_2_read.gd": { + "source_sha256": "1b5bbf0f5c58df39ec28cc867c18a1a14d293fd36459343a118a3e97bc31d33a", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": false, + "new_audio_QA": false } }, "outside_routes": { @@ -448,6 +471,9 @@ } }, "fixed_generated_report_routes": { + "tests/economy/test_p05_funds_restart_1_write.gd": [ + "artifacts/p05-02/restart-handoff.json" + ], "tests/mvp/test_runtime_upkeep.gd": [ "artifacts/upkeep-2026-09-29/preclose-snapshot.txt", "artifacts/upkeep-2026-09-29/closed-snapshot.txt" diff --git a/tools/run_p05_funds_tests.py b/tools/run_p05_funds_tests.py new file mode 100644 index 0000000..34361ca --- /dev/null +++ b/tools/run_p05_funds_tests.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +"""Root-owned narrow P05-02 (front and hidden funds, rank tax, loan interest) collection using the +unchanged guarded phase protocol of tools/run_p18_relationship_tests.py. After the roster, the F6 +restart writer and reader suites run as two more phases (separate engine processes); the writer's +only declared generated report is the save handoff the reader loads.""" +import argparse +import hashlib +import json +import os +import re +import stat +from pathlib import Path +import shutil +import sys +import uuid + +sys.dont_write_bytecode = True +import run_all_tests as shared + +ROSTER = [ + 'tests/economy/test_p05_funds_runtime.gd', + 'tests/mvp/test_original_midnight_settlement.gd', + 'tests/mvp/test_runtime_operations_integration.gd', + 'tests/s2/test_runtime_operations_cycle.gd', + 'tests/s6/test_runtime_save_lifecycle.gd', + 'tests/s6/test_runtime_snapshot_serialization.gd', + 'tests/supply/test_supply_management_service.gd', + 'tests/ui/test_upkeep_closed_ui.gd', +] +# P05-02 F6 (SPEC section 6): the writer saves, the reader in the next phase's engine resumes. +RESTART = [ + 'tests/economy/test_p05_funds_restart_1_write.gd', + 'tests/economy/test_p05_funds_restart_2_read.gd', +] +HANDOFF = 'artifacts/p05-02/restart-handoff.json' +SUITES = ROSTER + RESTART +WRITER_LINE = r'(?m)^P05_RESTART_WRITER pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$' +READER_LINE = r'(?m)^P05_RESTART_READER pid=([0-9]+) writer_pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$' + + +def collect(args, root, out, refs, markers, contracts, policy, contract_path, contract_sha, parse_paths): + source_bytes = sum(pin['bytes'] for pin in refs.values()) + free = shutil.disk_usage(out.parent).free + needed = source_bytes + shared.MIN_SPACE + shared.require(free >= needed, 'fresh source and phase disk admission') + outer = shared.module('p05_owned_outer', shared.SUPPORT / 'owned_outer.py') + shared.require(not outer.native.helper['live_engines'](), 'another Godot owns CPU') + engine = shutil.which('godot') + shared.require(engine and Path(engine).is_file(), 'native launcher exists') + engine = str(Path(engine).resolve()) + out.mkdir(parents=True, exist_ok=False) + stage = out / 'stage' + stage.mkdir() + (out / 'starters').mkdir() + (out / 'bootstrap').mkdir() + for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items(): + data = pin['text'].encode('utf-8') + shared.require(hashlib.sha256(data).hexdigest() == pin['sha256'], 'unchanged external bootstrap bytes') + (out / 'bootstrap' / name).write_bytes(data) + protected = shared.default_player(root) + expected = {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} for name, pin in refs.items()} + selected = {'root_before': refs, 'map': {name: pin['sha256'] for name, pin in refs.items()}, 'timeout': args.timeout} + for name, pin in refs.items(): + target = stage / name + target.parent.mkdir(parents=True, exist_ok=True) + shared.copy_source(Path(pin['path']), target) + shared.require(shared.inventory(stage, stage=True) == expected, 'complete private source copy') + public = (stage / 'project.godot').read_bytes() + shared.write(out / 'parse-paths.json', parse_paths) + run_id = uuid.uuid4().hex + shared.write(out / 'runner-input.json', dict(protocol='guarded-p05-02-v1', run_id=run_id, + owner_runner_pid=os.getpid(), project_root=str(root), stage=str(stage), roster=ROSTER, + restart_phases=RESTART, restart_handoff=HANDOFF, + selected_source_map=selected['map'], marker_contract_sha256=contract_sha, + runner_sha256=shared.sha(Path(__file__)), shared_runner_sha256=shared.sha(shared.HERE / 'run_all_tests.py'), + marker_module_sha256=shared.sha(shared.HERE / 'strict_test_markers.py'), + engine=engine, engine_sha256=shared.sha(engine), default_player_before=protected, + formal_acceptance=False, original_unit_complete=False)) + shared.write(out / 'source-start.json', refs) + diagnostics = shared.module('p05_diagnostics', shared.SUPPORT / 'diagnostic_gate.py') + owners = shared.module('p05_owners', shared.SUPPORT / 'ordinary_proof_gate.py') + audio = shared.module('p05_audio', shared.SUPPORT / 'audio_evidence_gate.py') + phases, rows, previous = [], [], {} + error = None + try: + for phase, test in [('import', None), ('parse', None)] + [(str(i).zfill(3), name) for i, name in enumerate(SUITES)]: + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged') + row = shared.execute_phase(engine, stage, out, phase, test, selected, expected, previous, + policy, contracts, parse_paths, public, protected, root, outer, markers, diagnostics, owners, audio) + archive_profiles(out, row) + phases.append(row) + if test: + row.update(sequence=len(rows), godot=engine) + rows.append(row) + (out / 'all-tests.json').write_text(json.dumps(rows, ensure_ascii=False, indent=2) + '\n') + print(('PASS ' if row['passed'] else 'FAIL ') + test, flush=True) + shared.require(row['passed'], 'first failed phase stops without retry: ' + phase) + except BaseException as caught: + error = type(caught).__name__ + ': ' + str(caught) + finally: + cleanup, cleanup_errors = outer.finish_captured(outer.OWNED) + guard_errors = [] + try: + shared.require(shared.source_refs(root) == refs, 'whole source unchanged') + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged at end') + shared.require(shared.default_player(root) == protected, 'default profile untouched') + shared.require(shared.inventory(stage, stage=True) == dict(expected, **previous), 'whole stage exact') + except BaseException as caught: + guard_errors.append(type(caught).__name__ + ': ' + str(caught)) + ended = bool(outer.OWNED) and all(row['engine_ended'] is True for row in cleanup) + passed = error is None and not cleanup_errors and not guard_errors and ended and len(phases) == len(SUITES) + 2 and all(row['passed'] for row in phases) + context = shared.totals(rows, SUITES) + shared.write(out / 'runner-ended.json', dict(context, protocol='guarded-p05-02-v1', run_id=run_id, + owner_runner_pid=os.getpid(), passed=passed, engine_ended=ended, + actual_captured_PIDs=[process.pid for process in outer.OWNED], final_owned_cleanup=cleanup, + cleanup_errors=cleanup_errors, guard_errors=guard_errors, error=error, phases=phases, + all_tests_sha256=shared.sha(out / 'all-tests.json') if (out / 'all-tests.json').exists() else None, + default_player_after=shared.default_player(root), formal_acceptance=False, + original_unit_complete=False, independent_review='pending', native_Windows='pending')) + if passed: + seal(out, refs, engine, contract_sha) + # Root also exercises precisely the verifier that ordinary Runner uses. + verify(out, refs, markers, contracts, policy, contract_sha, parse_paths) + print(json.dumps(dict(context, passed=passed, error=error, cleanup_errors=cleanup_errors, guard_errors=guard_errors)), flush=True) + return 0 if passed else 1 + + + +# Receipt file names are always relative; absolute paths in native receipts are +# historical identity strings only and are never opened by the verifier. +def require(ok, reason): + shared.require(ok, reason) + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False).encode()).hexdigest() + + +def content_map(refs): + return {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} + for name, pin in refs.items()} + + +def plain_path(path): + path = Path(path) + require('..' not in path.parts, 'parent traversal refused') + absolute = Path(os.path.abspath(path)) + for part in [absolute, *absolute.parents]: + require(not part.is_symlink(), 'symlink path refused: ' + str(part)) + return absolute + + +def confined(out, name): + require(type(name) is str and name and '\\' not in name, 'ordinary relative evidence name') + path = Path(name) + require(not path.is_absolute() and '..' not in path.parts + and path.as_posix() == name and name != '.', 'evidence path escape or alias') + result = plain_path(out / path) + require(result.is_relative_to(out), 'evidence confined to output') + return result + + +def inventory(out): + result = {} + require(out.is_dir(), 'output directory required') + for directory, dirs, files in os.walk(out, followlinks=False): + for name in dirs + files: + p = Path(directory) / name + mode = p.lstat().st_mode + require(stat.S_ISREG(mode) or stat.S_ISDIR(mode), 'nonordinary evidence refused') + if stat.S_ISREG(mode): + require(p.stat().st_nlink == 1, 'hardlinked evidence refused') + result[p.relative_to(out).as_posix()] = { + 'bytes': p.stat().st_size, 'sha256': shared.sha(p)} + return result + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'duplicate JSON key') + result[key] = value + return result + + +def read_json(path): + return json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=unique_object, + parse_constant=lambda token: require(False, 'nonfinite JSON ' + token)) + + +def archive_profiles(out, row): + for label in ('startup', 'runtime'): + value = row.get('whole_' + label + '_profile_after') + if value is None: + continue + dest = out / 'profiles' / row['phase'] / label + dest.mkdir(parents=True, exist_ok=False) + source = plain_path(value['path']) + require(shared.inventory(source) == value['files'], 'whole owned profile before archive') + for name in value['files']: + target = confined(out, str((dest / name).relative_to(out))) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / name, target) + require(shared.inventory(dest) == value['files'] == shared.inventory(source), + 'whole owned profile archive exact') + + +def seal(out, refs, engine, contract_sha): + # Last file written: an interrupted collection has no complete receipt. + shutil.copyfile(engine, out / 'engine.bin') + require(shared.sha(out / 'engine.bin') == shared.sha(engine), 'engine archive exact') + source = content_map(refs) + shared.write(out / 'receipt.json', { + 'schema': 'guarded-p05-02-v1', 'source_map': source, + 'parse_checks': len([name for name in refs if name.endswith('.gd')]), + 'source_map_sha256': digest(source), + 'project_settings_sha256': source['project.godot']['sha256'], + 'marker_contract_sha256': contract_sha, + 'engine_sha256': shared.sha(engine), + 'files': inventory(out), + }) + + +def cleanup_gate(value): + require(value['owned_child_created'] is True and value['engine_ended'] is True + and value['actual_process_exit_code'] == 0 and value['errors'] == [], + 'successful actual owned cleanup') + attempts = value['attempts'] + require(1 <= len(attempts) <= 3, 'bounded cleanup attempts') + for index, row in enumerate(attempts): + require(row['attempt'] == index + 1 and row['errors'] == [] + and row['wait_timeout_seconds'] == 5 and row['wait_completed'] is True + and row['wait_returned_code'] == 0 and row['poll_after'] == 0, + 'complete bounded owned wait') + + +def outer_cleanup(rows, pids): + require([r['pid'] for r in rows] == pids, 'exact captured cleanup PID order') + for row in rows: + require(row['engine_ended'] is True and row['outer_cleanup_errors'] == [] + and row['foreign_process_or_group_signals'] == 0 + and row['outer_maximum_helper_invocations'] == 3 + and row['helper_maximum_waits'] == 3 and row['per_wait_timeout_seconds'] == 5, + 'unchanged outer cleanup contract') + cleanup_gate(row['cleanup']) + + +def verify(out, refs, markers, contracts, policy, contract_sha, parse_paths): + receipt_path = confined(out, 'receipt.json') + require(receipt_path.is_file(), 'regular final receipt required') + manifest = read_json(receipt_path) + require(manifest['schema'] == 'guarded-p05-02-v1', 'current receipt schema required') + files = manifest['files'] + require(type(files) is dict and files, 'complete raw manifest') + for name, pin in files.items(): + p = confined(out, name) + require(p.is_file() and set(pin) == {'bytes', 'sha256'} + and type(pin['bytes']) is int and pin['bytes'] >= 0 + and re.fullmatch('[0-9a-f]{64}', pin['sha256']) is not None, + 'ordinary artifact pin') + actual = inventory(out) + receipt_pin = actual.pop('receipt.json') + require(actual == files, 'complete raw file set and SHA-256 equality') + source = content_map(refs) + require(manifest['source_map'] == source and manifest['source_map_sha256'] == digest(source), + 'current whole source content map independent of checkout path') + require(manifest['project_settings_sha256'] == source['project.godot']['sha256'] + and manifest['marker_contract_sha256'] == contract_sha, 'current project/marker pins') + + def read(name): + require(name in files, 'required raw file missing: ' + name) + return read_json(confined(out, name)) + + def text(name): + require(name in files, 'required raw transport missing: ' + name) + return confined(out, name).read_text(encoding='utf-8') + + start, end = read('runner-input.json'), read('runner-ended.json') + require(start['protocol'] == end['protocol'] == 'guarded-p05-02-v1' + and start['run_id'] == end['run_id'] + and re.fullmatch('[0-9a-f]{32}', start['run_id']) is not None, + 'same current run') + require(type(start['owner_runner_pid']) is int and start['owner_runner_pid'] > 0 + and start['owner_runner_pid'] == end['owner_runner_pid'], 'same collector owner') + require(start['roster'] == ROSTER and start['restart_phases'] == RESTART + and start['restart_handoff'] == HANDOFF and len(set(SUITES)) == len(SUITES), + 'exact unique roster and restart tests') + require(start['selected_source_map'] == {n: p['sha256'] for n, p in source.items()} + and content_map(read('source-start.json')) == source, 'whole selected source pins') + for field, name in [('runner_sha256', 'tools/run_p05_funds_tests.py'), + ('shared_runner_sha256', 'tools/run_all_tests.py'), + ('marker_module_sha256', 'tools/strict_test_markers.py')]: + require(start[field] == source[name]['sha256'], 'current collector/shared/marker pin') + require(start['marker_contract_sha256'] == contract_sha, 'marker contract pin') + # Resolve only the locally configured launcher, never the receipt's engine path. + engine = shutil.which('godot') + require(engine and shared.sha(engine) == start['engine_sha256'] + == manifest['engine_sha256'] == files['engine.bin']['sha256'], + 'current native engine and retained bytes pinned') + derived_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')] + require(read('parse-paths.json') == parse_paths == derived_paths + and manifest['parse_checks'] == len(derived_paths), + 'all exact source parse paths and receipt count') + require(end['passed'] is True and end['engine_ended'] is True and end['error'] is None + and end['cleanup_errors'] == [] and end['guard_errors'] == [], + 'finished successful collection without guard/cleanup error') + for receipt in (start, end): + require(receipt['formal_acceptance'] is False and receipt['original_unit_complete'] is False, + 'backend evidence never completes original unit') + before = start['default_player_before'] + require(before == end['default_player_after'] and before['logs_and_locks_included'] is True + and type(before['existed']) is bool and type(before['files']) is dict, + 'complete default player before/after equality') + for name, pin in before['files'].items(): + confined(out, name) # Validate the inventory name; never open the player. + require(set(pin) == {'bytes', 'sha256'} and type(pin['bytes']) is int + and pin['bytes'] >= 0 and re.fullmatch('[0-9a-f]{64}', pin['sha256']), + 'whole default player file pin') + require(before['existed'] or before['files'] == {}, 'absent player has no files') + + native = shared.module('p05_verify_native', shared.SUPPORT / 'owned_diagnostic_process.py') + diagnostics = shared.module('p05_verify_diagnostics', shared.SUPPORT / 'diagnostic_gate.py') + owners = shared.module('p05_verify_owners', shared.SUPPORT / 'ordinary_proof_gate.py') + # Helpers are loaded for their pure gates only. No run/probe/cleanup is called. + origin = Path(start['stage']).parent + require(Path(start['stage']) == origin / 'stage' and origin.is_absolute() + and '..' not in origin.parts, 'original output identity') + + def relocated(value): + if isinstance(value, dict): + return {relocated(k): relocated(v) for k, v in value.items()} + if isinstance(value, list): + return [relocated(v) for v in value] + if isinstance(value, str) and value.startswith(str(origin) + '/'): + return str(confined(out, value[len(str(origin)) + 1:])) + return value + + def proof_read(path): + path = plain_path(path) + require(path.is_relative_to(out), 'proof path stays in output') + return relocated(read(path.relative_to(out).as_posix())) + + owners.read = proof_read + phases = end['phases'] + require(len(phases) == len(SUITES) + 2, 'exact import, parse, roster and restart phases') + tests = read('all-tests.json') + stage_files = shared.inventory(confined(out, 'stage'), stage=True) + require(set(stage_files) == set(source) | {HANDOFF}, 'retained stage is the source and the restart handoff') + handoff = stage_files[HANDOFF] + require(len(tests) == len(SUITES) and end['all_tests_sha256'] == files['all-tests.json']['sha256'], + 'complete test rows') + pids = [] + for index, (phase, test) in enumerate([('import', None), ('parse', None)] + + [(str(i).zfill(3), n) for i, n in enumerate(SUITES)]): + row = read(phase + '-receipt.json') + expected_row = dict(row) + if test: + expected_row.update(sequence=index - 2, godot=start['engine']) + require(tests[index - 2] == expected_row, 'test row matches raw phase receipt') + require(phases[index] == expected_row and row['phase'] == phase and row['test'] == test + and row['passed'] is True and row['failed'] is False + and row['validation_error'] is None and not row.get('guard_error') + and row['default_player_entire_profile_unchanged'] is True, + 'exact successful phase') + pid = row['actual_pid'] + require(type(pid) is int and pid > 0 and pid not in pids, 'distinct actual PIDs per phase') + pids.append(pid) + process = read(phase + '/process.json') + require(process == row['native_process'] and process['pid'] == pid + and process['owned_child_process_helper_sha256'] == native.HELPER_SHA + == source['tools/regression/p09_owned_engine_process.py']['sha256'], + 'actual phase/process/helper agreement') + stdout, stderr, logger = (text(phase + '/' + n) for n in + ('stdout.log', 'stderr.log', 'engine-native.log')) + raw = stdout + stderr + require(text(row['log']) == raw and row['log_sha256'] == files[row['log']]['sha256'], + 'literal complete raw stream') + require(process['stdout_sha256'] == files[phase + '/stdout.log']['sha256'] + and process['stderr_sha256'] == files[phase + '/stderr.log']['sha256'], + 'process transport SHA') + require(row['raw_native_log'] == dict(files[phase + '/engine-native.log'], + path=str(origin / phase / 'engine-native.log')), 'native logger pin') + review = diagnostics.review_streams(native, { + 'stdout': stdout, 'stderr': stderr, 'native_file': logger}, test) + require(row['finite_original_diagnostic_review'] == review + and diagnostics.diagnostic_result_gate(process, review) + and shared.process_gate(process, review['allowed_e31_error_lines']), + 'strict diagnostics/process gates') + require(native.native_diagnostics(stdout, stderr, row['command']) + == process['strict_native_diagnostics'], 'raw banner/diagnostics recomputed') + command = [start['engine'], '--headless', '--path', start['stage']] + if phase == 'import': + command += ['--editor', '--import', '--quit', '--verbose'] + else: + script = ('bootstrap/parse_full_dependencies.gd' if phase == 'parse' + else 'starters/' + phase + '.gd') + command += ['--script', str(origin / script), '--verbose'] + require(row['starter_sha256'] == files[script]['sha256'], 'exact starter pin') + if test: + expected_starter = ('extends ' + json.dumps('res://' + test) + '\nconst Guard := preload(' + + json.dumps(str(origin / 'bootstrap/profile_guard.gd')) + + ')\nfunc _initialize() -> void:\n\tif not Guard.activate():\n\t\tquit(97)\n\t\treturn\n\tsuper._initialize()\n') + require(text(script) == expected_starter, 'unchanged guarded starter bytes') + command += ['--log-file', str(origin / phase / 'engine-native.log')] + require(row['command'] == process['argv'] == command, 'exact native argv/logger') + for filename, field, event in [('started.json', 'actual_start_binding', 'ENGINE_STARTED'), + ('ended.json', 'actual_end_binding', 'ENGINE_ENDED')]: + binding = read(phase + '/' + filename) + require(binding == process[field] and binding['event'] == event + and binding['actual_pid'] == binding['pid'] == pid + and binding['owner_pid'] == start['owner_runner_pid'] + and binding['command'] == binding['argv'] == command, 'actual START/END ownership') + binding = process['actual_start_binding'] + identity = binding['current_process_identity'] + if identity is None: + require(binding['tracked_child_ended_before_initial_probe'] is True, 'early ended tracked child') + else: + require(binding['live_identity_exact'] is True and identity == { + 'pid': pid, 'ppid': start['owner_runner_pid'], 'pgid': pid, 'args': ' '.join(command)}, + 'recorded live child PID/PPID/PGID/argv') + finish = process['actual_end_binding'] + require(finish['engine_ended'] is True and finish['timed_out'] is False + and finish['lifecycle_error'] is None and finish['exit_code'] == 0 + and finish['actual_process_exit_code'] == 0 + and finish['start_receipt_written'] is True + and finish['monotonic_ns'] >= binding['monotonic_ns'], 'complete native END') + cleanup = read(phase + '/adapter-owned-cleanup.json') + require(cleanup == process['adapter_owned_cleanup'], 'raw adapter cleanup') + cleanup_gate(cleanup) + capture = row['outer_actual_capture'] + require(read(phase + '/outer-owned-finally.json') == { + k: v for k, v in capture.items() + if k not in ('outer_receipt_written', 'outer_receipt_error', 'passed')}, + 'whole raw outer receipt') + require(capture['actual_captured_pids'] == [pid] and capture['passed'] is True + and capture['all_captured_ended'] is True and capture['adapter_exception'] is None + and capture['outer_cleanup_errors'] == [] and capture['outer_receipt_error'] is None + and capture['outer_receipt_written'] is True + and capture['all_cleanup_before_outer_receipt'] is True + and capture['foreign_process_or_group_signals'] == 0 + and row['engine_ended'] is True and row['exit_code'] == 0, 'owned outer capture') + outer_cleanup(capture['cleanup'], [pid]) + startup = read(phase + '-startup-profile-before.json') + require(startup == {'path': row['whole_startup_profile_after']['path'], 'existed': False, + 'files': {}, 'before_native_spawn': True}, 'fresh startup profile') + require(row['whole_startup_profile_after']['fresh_before_engine'] is True, + 'startup fresh before engine') + name = row['profile_startup_name'] + require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-startup-' + phase, name) + and Path(startup['path']).name == name and row['private_project_fields'] == 2 + and row['private_project_sha256'] == hashlib.sha256(shared.private_project( + (shared.HERE.parent / 'project.godot').read_bytes(), name)).hexdigest(), + 'exact two-field startup isolation') + for label in ('startup', 'runtime'): + if label == 'runtime' and phase == 'import': + continue + whole = row['whole_' + label + '_profile_after'] + archive = confined(out, 'profiles/' + phase + '/' + label) + require(archive.is_dir() and shared.inventory(archive) + == whole['files'], 'complete retained private profile') + if phase != 'import': + guard = read(phase + '-profile.json') + runtime = Path(row['whole_runtime_profile_after']['path']) + name = guard['custom_user_dir_name'] + require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-runtime-' + phase, name) + and runtime.name == name and guard['verified'] is True + and guard['conditions'] == [True] * 5 and guard['conditions_checked'] == 5 + and guard['fresh_before_parent_initialize'] is True + and guard['profile_writes_before_guard'] == 0 + and guard['actual_user_data_dir'] == guard['expected_user_data_dir'] == str(runtime) + and guard['globalized_probe'] == str(runtime / 'guard-probe') + and guard['project_settings_persisted'] is False, 'all unchanged Guard conditions') + require(row['actual_private_profile'] == { + 'report': guard, 'report_sha256': files[phase + '-profile.json']['sha256'], + 'whole_files': row['whole_runtime_profile_after']['files']}, 'whole raw Guard report') + require(row['controlled_environment'] == { + 'THE_TREE_PACK_PROFILE_NAME': name, 'THE_TREE_PACK_PROFILE_EXPECTED': str(runtime), + 'THE_TREE_PACK_PROFILE_REPORT': str(origin / (phase + '-profile.json')), + 'THE_TREE_FULL_PARSE_PATHS': str(origin / 'parse-paths.json'), + **({'THE_TREE_AUDIO_TEARDOWN_PROOF_DIR': str(origin / (phase + '-teardown/owned-audio')), + 'THE_TREE_AUDIO_FIXTURE_CONTROL_PROOF': str(origin / (phase + '-teardown/control.json'))} + if test and policy['suite_policies'].get(test, {}).get('ordinary_cleanup_prepared') else {})}, + 'exact controlled Guard/proof environment') + else: + require(row['controlled_environment'] == {}, 'fresh import environment') + if phase == 'parse': + require(len(re.findall(r'(?m)^FULL_DEPENDENCY_PARSE\b', raw)) == 1 + and re.findall(r'(?m)^FULL_DEPENDENCY_PARSE checks=([0-9]+) failures=([0-9]+)\n', raw) + == [(str(len(parse_paths)), '0')] and row['actual_parse_checks'] == len(parse_paths), 'unique all-script parse marker') + if test: + parsed = markers.parse_raw(test, raw, contracts, source[test]['sha256']) + require(parsed['passed'] and row['completion'] == parsed + and row['reported_checks'] == parsed['checks'] + and row['reported_failures'] == parsed['failures'] + and row['completion_marker'] == parsed['marker'] and row['collector_errors'] == [], + 'source-pinned raw completion marker') + require(row['source_sha256'] == row['source_sha256_end'] == source[test]['sha256'], + 'exact test source before/after') + p = shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) + require(row['policy'] == p, 'current exact suite policy') + if p['ordinary_cleanup_prepared']: + proof = owners.ordinary_gate(confined(out, phase + '-teardown'), pid, out, + p['ordinary_control_labels']) + require(proof == relocated(row['ordinary_owner_pairs']), 'full ordinary/audio ownership proof') + if test == RESTART[0]: + require(row['generated_reports'] == {HANDOFF: dict(handoff, actual_engine_pid=pid, + producer_test=test, producer_source_sha256=source[test]['sha256'], payload_modified=False)}, + 'the writer generates exactly the retained restart handoff') + require(re.findall(WRITER_LINE, raw) == [(str(pid), handoff['sha256'])] + and read_json(confined(out, 'stage/' + HANDOFF))['writer_pid'] == pid, + 'the writer engine printed and recorded its own handoff') + writer_pid = pid + else: + require(row['generated_reports'] == {}, 'only the restart writer generates a report') + if test == RESTART[1]: + require(re.findall(READER_LINE, raw) == [(str(pid), str(writer_pid), handoff['sha256'])] + and pid != writer_pid, 'a separate reader engine read the exact writer handoff') + require(end['actual_captured_PIDs'] == pids, 'all captured engines') + outer_cleanup(end['final_owned_cleanup'], pids) + require(all(end[k] == v for k, v in shared.totals(tests, SUITES).items()), 'recomputed exact totals') + require(shared.inventory(confined(out, 'stage'), stage=True) == dict(source, **{HANDOFF: handoff}), + 'entire retained source/settings and handoff exact') + for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items(): + require(text('bootstrap/' + name).encode() == pin['text'].encode() + and files['bootstrap/' + name]['sha256'] == pin['sha256'], 'unchanged bootstrap/Guard pins') + require(inventory(out) == dict(files, **{'receipt.json': receipt_pin}), + 'whole raw evidence unchanged during verification') + require(content_map(shared.source_refs(shared.HERE.parent)) == source, + 'whole current source unchanged during verification') + print(json.dumps({'verified': True, 'actual_suites': len(SUITES), 'parse_checks': len(parse_paths), + 'actual_checks': end['actual_all_roster_checks'], 'engines_started': 0, + 'formal_acceptance': False, 'original_unit_complete': False})) + return 0 + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + # No argparse abbreviation, repeated flags, = aliases or positional tokens. + parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + parser.add_argument('--output', required=True) + modes = parser.add_mutually_exclusive_group() + modes.add_argument('--collect', action='store_true', help='Root only; fresh native collection') + modes.add_argument('--prepare-only', action='store_true') + seen = set() + i = 0 + while i < len(argv): + flag = argv[i] + require(flag in ('--output', '--collect', '--prepare-only', '--help') + and flag not in seen, 'unknown, duplicate or malformed flag') + seen.add(flag) + if flag == '--output': + i += 1 + require(i < len(argv) and argv[i] and not argv[i].startswith('--'), 'output value required') + i += 1 + args = parser.parse_args(argv) + args.timeout = 1200 + root = shared.HERE.parent + out = plain_path(args.output) + refs = shared.source_refs(root) + require(set(SUITES).issubset(shared.roster(root)), 'normal discovery includes narrow roster and restart tests') + discovered = shared.roster(root) + require(discovered.index(RESTART[0]) < discovered.index(RESTART[1]), 'the full roster also runs the writer first') + markers = shared.module('p05_exact_markers', shared.HERE / 'strict_test_markers.py') + contract_path = root / 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json' + contract_sha = shared.sha(contract_path) + contracts = markers.load_contracts(contract_path) + require(all(name in contracts for name in SUITES), 'complete marker roster') + policy = shared.read(shared.SUPPORT / 'suite-policy.json') + shared.policy_coverage(SUITES, refs, policy) + for test in SUITES: + shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) + require(test not in policy['outside_routes'], 'no outside report routes in the narrow run') + require(policy['fixed_generated_report_routes'].get(test, []) == ([HANDOFF] if test == RESTART[0] else []), + 'only the restart writer declares one generated report') + for test in ROSTER[:1] + RESTART: + require(contracts[test]['source_sha256_at_schema_audit'] == refs[test]['sha256'], + 'new strict source-hashed marker') + parse_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')] + if args.prepare_only: + ancestor = out.parent + while not ancestor.exists(): + ancestor = ancestor.parent + free = shutil.disk_usage(ancestor).free + size = sum(p['bytes'] for p in refs.values()) + print(json.dumps({'roster': ROSTER, 'source_files': len(refs), 'source_bytes': size, + 'free_bytes': free, 'conservative_required_bytes': size + shared.MIN_SPACE, + 'capacity_available': free >= size + shared.MIN_SPACE, 'parse_paths': len(parse_paths), + 'restart_phases': RESTART, 'future_native_phases': len(SUITES) + 2, 'actual_checks': None, 'engines': 0, 'stages': 0, + 'formal_acceptance': False})) + return 0 + if args.collect: + require(not out.exists() and out.parent.is_dir(), 'fresh output with existing parent required') + require(not out.is_relative_to(root), 'private collection outside source tree') + return collect(args, root, out, refs, markers, contracts, policy, + contract_path, contract_sha, parse_paths) + return verify(out, refs, markers, contracts, policy, contract_sha, parse_paths) + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (AssertionError, OSError, ValueError, KeyError, TypeError) as error: + print(type(error).__name__ + ': ' + str(error), file=sys.stderr) + raise SystemExit(1)