P05-02: 全費目と資金区分:表金・裏金、維持費未払い、仕入れ、人件費、元本、利息、税の帳簿への接続(原 P05-02 の完了の条件)

This commit is contained in:
Agent Control Runner
2026-10-23 16:02:54 +09:00
parent 613b9886da
commit 7808ca76db
20 changed files with 1906 additions and 17 deletions
+211 -13
View File
@@ -909,6 +909,9 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
return false return false
if not FacilityUpkeepSettlement.prepare_accounts(restored_economy) or not restored_upkeep.start_at(restored_minute)[&"ok"]: if not FacilityUpkeepSettlement.prepare_accounts(restored_economy) or not restored_upkeep.start_at(restored_minute)[&"ok"]:
return false 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) var saved_resources: Variant = snapshot[&"game_state"].get(&"resources", null)
if not saved_resources is Dictionary \ if not saved_resources is Dictionary \
or int(saved_resources.get(&"gold", saved_resources.get("gold", -1))) \ 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 var last_close := 0
if operations_coordinator != null and operations_coordinator.get_open_financial_day() > 1: 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) 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: 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: 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) var prepared := _run_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", payload, OPERATIONS_TEST_PREPARE_PHASE, token)
if not bool(prepared.get(&"ok", false)): if not bool(prepared.get(&"ok", false)):
return prepared 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)) var result := _settle_upkeep_through(int(minute))
if not bool(result[&"ok"]): if not bool(result[&"ok"]):
return result return result
@@ -2524,10 +2537,29 @@ func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dicti
if not bool(prepare_hook.get(&"ok", false)): if not bool(prepare_hook.get(&"ok", false)):
return prepare_hook return prepare_hook
var day := int(payload[&"day"]) var day := int(payload[&"day"])
var boundary_minute := int(payload[&"boundary_minute"])
var preview := staff_service.preview_daily_payroll(day) var preview := staff_service.preview_daily_payroll(day)
if not bool(preview.get(&"ok", false)) \ if not bool(preview.get(&"ok", false)) \
or not preview.get(&"rows", null) is Array: or not preview.get(&"rows", null) is Array:
return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_FAILED"} 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 available_gold := economy_service.get_balance(PLAYER_CASH_ACCOUNT)
var remaining_gold := available_gold var remaining_gold := available_gold
var payments: Dictionary = {} var payments: Dictionary = {}
@@ -2592,6 +2624,17 @@ func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dicti
if not bool(upkeep_result[&"ok"]): if not bool(upkeep_result[&"ok"]):
return upkeep_result return upkeep_result
var upkeep_paid := int(upkeep_result[&"paid_total"]) 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( var financials := _build_operations_daily_financials(
int(payload[&"period_start_minute"]), int(payload[&"period_start_minute"]),
int(payload[&"period_end_minute_exclusive"]), 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)) var arrears_total := int(settlement.details.get(&"unpaid_after_total", -1))
if arrears_total < 0 \ if arrears_total < 0 \
or arrears_total != _staff_payroll_arrears_total(staff_service) \ 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 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(economy_service.validate_ledger().get(&"ok", false)) \
or not bool(staff_service.validate_invariants().get(&"ok", false)): or not bool(staff_service.validate_invariants().get(&"ok", false)):
return {&"ok": false, &"reason": &"DAILY_CLOSE_POSTCONDITION_FAILED"} 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) var settlement_report := settlement.details.duplicate(true)
settlement_report[&"upkeep_arrears_total"] = upkeep_settlement.get_arrears_total() 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 { return {
&"ok": true, &"ok": true,
&"category_totals": financials[&"category_totals"], &"category_totals": financials[&"category_totals"],
@@ -2647,6 +2695,8 @@ func _build_operations_daily_financials(
&"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION",
&"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION",
&"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT",
FinancePolicy.TOP_UP_CATEGORY, FinancePolicy.RANK_TAX_PAYMENT_CATEGORY,
FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY,
] ]
var category_mapping := { var category_mapping := {
&"SERVICE_SALE": &"revenue", &"SERVICE_SALE": &"revenue",
@@ -2660,6 +2710,8 @@ func _build_operations_daily_financials(
&"EVENT_COST_COMMITTED": &"cost_of_goods", &"EVENT_COST_COMMITTED": &"cost_of_goods",
&"DEFENSE_SKILL_COST": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods",
&"MAINTENANCE_ACCRUAL": &"maintenance", &"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_BORROWED": &"financing_principal_inflows",
&"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows", &"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows",
} }
@@ -3551,8 +3603,22 @@ func _operations_payroll_crosslinks_are_valid(
summary_settlement.erase(&"upkeep_arrears_total") summary_settlement.erase(&"upkeep_arrears_total")
elif upkeep_debt != 0: elif upkeep_debt != 0:
return false 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)) \ 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: or summary_settlement.get(&"replayed", null) != false:
return false return false
summary_settlement.erase(&"arrears_total") 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.") return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "The loan liability does not exist.")
var remaining := -economy_service.get_balance(liability) var remaining := -economy_service.get_balance(liability)
var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT) 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.") 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 # A09: what the front fund cannot pay comes from the hidden fund in the same
# mirror is synchronized, also respecting any enclosing effect scope. # 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() economy_service.begin_notification_scope()
var result := economy_service.transfer( var result := economy_service.post(entries, idempotency_key)
PLAYER_CASH_ACCOUNT, liability, amount, _runtime_event_minute(),
&"LOAN_PRINCIPAL_REPAID", loan_id, idempotency_key
)
if result.is_success(): if result.is_success():
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT)) _state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
result.details[&"loan_id"] = loan_id result.details[&"loan_id"] = loan_id
@@ -3644,6 +3715,125 @@ func repay_loan_principal(
return result 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: func _loan_principal_ledger_is_valid(economy: EconomyService) -> bool:
if economy == null: if economy == null:
return false return false
@@ -3697,6 +3887,8 @@ func _operations_expected_category_totals(
&"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION",
&"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION",
&"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT",
FinancePolicy.TOP_UP_CATEGORY, FinancePolicy.RANK_TAX_PAYMENT_CATEGORY,
FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY,
] ]
var mapping := { var mapping := {
&"SERVICE_SALE": &"revenue", &"REFUND": &"refunds", &"SERVICE_SALE": &"revenue", &"REFUND": &"refunds",
@@ -3707,6 +3899,8 @@ func _operations_expected_category_totals(
&"EVENT_COST_COMMITTED": &"cost_of_goods", &"EVENT_COST_COMMITTED": &"cost_of_goods",
&"DEFENSE_SKILL_COST": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods",
&"MAINTENANCE_ACCRUAL": &"maintenance", &"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_BORROWED": &"financing_principal_inflows",
&"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows", &"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows",
} }
@@ -5254,6 +5448,7 @@ func _initialize_economy() -> void:
facility_upkeep = FacilityUpkeepAccrual.new() facility_upkeep = FacilityUpkeepAccrual.new()
upkeep_settlement = FacilityUpkeepSettlement.new() upkeep_settlement = FacilityUpkeepSettlement.new()
FacilityUpkeepSettlement.prepare_accounts(economy_service) FacilityUpkeepSettlement.prepare_accounts(economy_service)
FinancePolicy.prepare_accounts(economy_service)
facility_upkeep.start_at(_clock.absolute_minute) facility_upkeep.start_at(_clock.absolute_minute)
mana_reserve = ManaReserveCycle.new() mana_reserve = ManaReserveCycle.new()
mana_reserve.start_at(_clock.absolute_minute) mana_reserve.start_at(_clock.absolute_minute)
@@ -11960,6 +12155,9 @@ func _apply_service_transaction(
if service.price_snapshot > 0: if service.price_snapshot > 0:
var payer_account := _guest_wallet_account(request.guest_ids[0]) var payer_account := _guest_wallet_account(request.guest_ids[0])
var revenue_account := _facility_revenue_account(facility.instance_id) 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): if not economy_service.has_account(payer_account) or not economy_service.has_account(revenue_account):
inventory_service.restore_snapshot(inventory_before) inventory_service.restore_snapshot(inventory_before)
return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "A service settlement account is missing.") 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 service.price_snapshot, &"SERVICE_SALE", facility.instance_id
), ),
LedgerEntry.new().configure( 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 service.price_snapshot, &"SERVICE_CASH_SWEEP", facility.instance_id
), ),
] ]
@@ -3925,6 +3925,39 @@
"text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])" "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])"
} }
] ]
},
{
"test": "tests/economy/test_p05_funds_runtime.gd",
"template": "PASS: <checks> 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> 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> 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", "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting",
@@ -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"
}
@@ -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=<SHA>` を1行だけ出す。
- 読み手は次の段の別の engine の process で、handoff を読み(書き手の PID が自分と違うこと、書き手の source の SHA が今のものと同じことを確かめる)、`P05_RESTART_READER pid=<読み手の PID> writer_pid=<書き手の PID> handoff_sha256=<SHA>` を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 が照合する。
@@ -65,6 +65,33 @@ func settle(period: Dictionary, economy: EconomyService) -> Dictionary:
return {&"ok": true, &"replayed": false, &"receipt": receipt.duplicate(true), &"transaction_id": transaction_id} 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: func get_arrears_through(minute: int) -> int:
var low := 0 var low := 0
var high := _receipts.size() var high := _receipts.size()
+86
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://l14tq0avw0ux
+13 -4
View File
@@ -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 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"])) var frozen := PurchaseQuoteV2.make(item_id, quantity, int(item[&"unit_price"]))
if frozen.is_empty(): return quote 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) 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") @warning_ignore("integer_division")
var slots := space / int(item[&"unit_weight"]) 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: 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 item := SupplyCatalog.get_item(item_id)
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_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} 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" 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"): if quote.has(&"purchase_quote"):
memo = PurchaseQuoteV2.receipt_memo(quote[&"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) 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(): if not paid.is_success():
runtime.inventory_service.restore_snapshot(inventory_before) 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.inventory_service.restore_snapshot(inventory_before)
runtime.economy_service.restore_snapshot(economy_before) runtime.economy_service.restore_snapshot(economy_before)
return _purchase_failure(command_id, &"PAYMENT_FAILED") 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.inventory_service.restore_snapshot(inventory_before)
runtime.economy_service.restore_snapshot(economy_before) runtime.economy_service.restore_snapshot(economy_before)
if record_auto_attempt: if record_auto_attempt:
@@ -274,7 +283,7 @@ func get_report(runtime: Variant) -> Dictionary:
row[&"reason_codes"].append(&"NO_INPUT") row[&"reason_codes"].append(&"NO_INPUT")
destinations.append(row) destinations.append(row)
var bridge_report: Dictionary = runtime.supply_logistics.get_report() 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: func ensure_deliveries(runtime: Variant, minute: int) -> void:
+22
View File
@@ -76,9 +76,17 @@ func get_report() -> Dictionary:
var construction_outflow := 0 var construction_outflow := 0
var wage_outflow := 0 var wage_outflow := 0
var upkeep_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(): for entry: LedgerEntry in _runtime.economy_service.get_entries():
if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION": if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION":
continue 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": if entry.debit_account == &"PLAYER_CASH":
inflow += entry.amount inflow += entry.amount
if entry.credit_account == &"PLAYER_CASH": if entry.credit_account == &"PLAYER_CASH":
@@ -89,6 +97,10 @@ func get_report() -> Dictionary:
upkeep_outflow += entry.amount upkeep_outflow += entry.amount
elif entry.category == &"WAGE": elif entry.category == &"WAGE":
wage_outflow += entry.amount 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] = [] var summaries: Array[Dictionary] = []
if _runtime.operations_coordinator != null: if _runtime.operations_coordinator != null:
summaries = _runtime.operations_coordinator.get_day_summaries() 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, &"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow,
&"upkeep_cash_out": upkeep_outflow, &"upkeep_cash_out": upkeep_outflow,
&"upkeep_arrears": _runtime.upkeep_settlement.get_arrears_total(), &"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), &"closed_days": summaries, &"selected_close": selected.duplicate(true),
} }
@@ -117,6 +134,8 @@ func refresh() -> void:
_refresh_repayment_controls() _refresh_repayment_controls()
var report := get_report() 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"]] _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"] var summary: Dictionary = report[&"selected_close"]
if summary.is_empty(): if summary.is_empty():
if not _day_picker.disabled or _day_picker.item_count == 0: 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"]: for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]:
other += int(totals.get(key, 0)) 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 "差額あり"] _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"): 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))] _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"])): if _runtime.operations_coordinator.is_legacy_principal_profit_history(int(summary[&"day"])):
+22
View File
@@ -300,6 +300,28 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
old[&"elevators"] = raised old[&"elevators"] = raised
fields.append("elevators_v1_to_v2") fields.append("elevators_v1_to_v2")
proof["elevators"] = {"only_version_changed": same_otherwise} 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(): if not fields.is_empty():
# Saves keep the Runtime's own key order, so the mapped tree follows it. # Saves keep the Runtime's own key order, so the mapped tree follows it.
ok = ok and old.size() == now.size() ok = ok and old.size() == now.size()
@@ -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)
@@ -0,0 +1 @@
uid://bsck1wvucocy3
@@ -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)
@@ -0,0 +1 @@
uid://c8yrv3udeb87l
+462
View File
@@ -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")
@@ -0,0 +1 @@
uid://2v2xxx0grnx8
+161
View File
@@ -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)
@@ -0,0 +1 @@
uid://m3lik2gqaho2
+26
View File
@@ -414,6 +414,29 @@
], ],
"old_bounded_teardown": false, "old_bounded_teardown": false,
"new_audio_QA": 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": { "outside_routes": {
@@ -448,6 +471,9 @@
} }
}, },
"fixed_generated_report_routes": { "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": [ "tests/mvp/test_runtime_upkeep.gd": [
"artifacts/upkeep-2026-09-29/preclose-snapshot.txt", "artifacts/upkeep-2026-09-29/preclose-snapshot.txt",
"artifacts/upkeep-2026-09-29/closed-snapshot.txt" "artifacts/upkeep-2026-09-29/closed-snapshot.txt"
+605
View File
@@ -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)