Merge commit '895491456477b42a74e59fa239bea86c4fb9aa33' into HEAD

This commit is contained in:
Agent Control Runner
2026-10-23 22:38:54 +09:00
23 changed files with 2392 additions and 29 deletions
+249 -14
View File
@@ -15,6 +15,8 @@ const STARTER_MANA_OUTPUT := 100
const STARTER_MANA_SOURCE := &"world_tree.mana_source" const STARTER_MANA_SOURCE := &"world_tree.mana_source"
const STARTER_MANA_BUS := &"world_tree.mana_bus" const STARTER_MANA_BUS := &"world_tree.mana_bus"
const PLAYER_CASH_ACCOUNT := &"PLAYER_CASH" const PLAYER_CASH_ACCOUNT := &"PLAYER_CASH"
## The settlement.funds report definition (see _daily_fund_report).
const FUND_REPORT_VERSION := 2
const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE" const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE"
const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE" const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE"
const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE" const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE"
@@ -909,6 +911,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 +1940,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 +2512,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 +2539,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 +2626,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 +2648,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 +2697,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 +2712,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 +3605,32 @@ 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
# A saved report is checked by the definition it was written with: without
# the mark it is version 1; any other mark is not a report this game wrote.
var fund_version := FUND_REPORT_VERSION
if summary_settlement.get(&"funds", null) is Dictionary:
var saved_funds: Dictionary = summary_settlement[&"funds"]
var mark: Variant = saved_funds.get(&"fund_report_version", saved_funds.get("fund_report_version", null))
if mark == null:
fund_version = 1
elif typeof(mark) != TYPE_INT or int(mark) != FUND_REPORT_VERSION:
return false
var expected_funds := _daily_fund_report(economy, day, coordinator.get_financial_period_start(day),
coordinator.get_daily_close_minute(day), fund_version)
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")
@@ -3599,7 +3677,10 @@ func get_loan_principal_report() -> Dictionary:
loans.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"loan_id"]) < String(right[&"loan_id"])) loans.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"loan_id"]) < String(right[&"loan_id"]))
var valid: bool = _state.get_resource(&"gold") == cash and (loans.is_empty() or _loan_principal_ledger_is_valid(economy_service)) var valid: bool = _state.get_resource(&"gold") == cash and (loans.is_empty() or _loan_principal_ledger_is_valid(economy_service))
var ended: bool = _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal() var ended: bool = _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal()
return {&"available": true, &"ledger_valid": valid, &"terminal": ended, &"cash": cash, &"next_command_id": _next_command_id, &"loans": loans} # A09: a repayment may use the hidden fund for what the front fund (cash) cannot pay.
return {&"available": true, &"ledger_valid": valid, &"terminal": ended, &"cash": cash,
&"back_cash": economy_service.get_balance(FinancePolicy.BACK_CASH), &"available_funds": FinancePolicy.available_funds(economy_service),
&"next_command_id": _next_command_id, &"loans": loans}
## Atomic principal payment against an already attested loan liability. ## Atomic principal payment against an already attested loan liability.
@@ -3626,15 +3707,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 +3730,144 @@ 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. Version 2 (marked in the report):
## back_top_up is every top-up of the business day, the operation-time purchases
## and repayments and the legacy 00:00 upkeep as well as the close's own. A report
## without the mark is version 1, written while back_top_up counted only the
## close's own top-up; a restore checks it by that definition, not a looser one.
func _daily_fund_report(economy: EconomyService, day: int, period_start: int, boundary_minute: int,
version := FUND_REPORT_VERSION) -> Dictionary:
if economy == null or day <= 0 or period_start < 0 or boundary_minute <= period_start \
or version not in [1, FUND_REPORT_VERSION]:
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 {}
if version == FUND_REPORT_VERSION:
# The closes are keyed; the previous day's close is posted at period_start.
var closes := {}
for close_day: int in [day - 1, day]:
var close := economy.get_transaction_id_for_idempotency_key(StringName("finance.back_top_up.day.%08d" % close_day))
if not close.is_empty():
closes[close] = true
for entry: LedgerEntry in economy.get_entries():
if entry.category == FinancePolicy.TOP_UP_CATEGORY and entry.absolute_minute >= period_start \
and entry.absolute_minute < boundary_minute and not closes.has(entry.transaction_id):
report[&"back_top_up"] = int(report[&"back_top_up"]) + entry.amount
postings = true
report[&"fund_report_version"] = FUND_REPORT_VERSION
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 +3921,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 +3933,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 +5482,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 +12189,12 @@ 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)
# Only a registered definition with a hidden-service tag routes the sale to the
# hidden fund. A facility whose definition cannot be read keeps the existing
# front-fund behaviour (disclosed in execution/p05-02/REPAIR_02.md).
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 +12204,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": "4c8f929b0de76198182dfaaef4be4bea7961faa9c31c01889497d6675e0c5def",
"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 が照合する。
@@ -0,0 +1,6 @@
# P05-02 修正1: 登録の validation の出力先
- S `7808ca76db5f997096cdfbb0edfad0c45479e238` の正式の validation(`p05-funds`)は、照合の入口で失敗した(生の stderr: `AssertionError: regular final receipt required`)。native の収集(10 suites・765 checks・verify true)と build の場所への写しは成功していた。
- 原因: 登録の argv は `--output {build_dir}/p05-funds-evidence`。今の Runner は validation の argv の `{python}` だけを置き換え、`{build_dir}` は文字どおり渡し、build の場所を環境変数 `BUILD_OUTPUT_DIR` で渡す。`tools/run_p05_funds_tests.py` は P18 の runner の写しで、置き換えを Runner に任せていたため、作業ツリーの中の存在しない path を照合した。
- 修正: `tools/run_p05_funds_tests.py` は、`--output` が `{build_dir}/` で始まるとき、`BUILD_OUTPUT_DIR`(絶対 path で、symlink でない既存のディレクトリ)で置き換える。それ以外の値の扱い、収集と照合の中身、登録の argv、試験、製品のコードは変えない。
- 新しい S では native の収集をやり直す(runner の SHA が変わり、S `7808ca7` の証拠の source map と合わないため)。S `7808ca7` の収集と失敗の validation の記録は保持する。
@@ -0,0 +1,32 @@
# P05-02 修正2: 実際の経路での表裏の資金・補填・利息・税の滞納と、日次の補填の定義
- S `37deb6e877e60e8f19ae4fbc64382880cf7f75bb` の whole-unit の独立レビュー(r02)は changes_requested(指摘 1 high・2 medium・3 low)。正規の受付で `repair_pending`、commander の修正2(`COMMANDER_REPAIR_PLAN_P05_02_R02.md`、訂正の追記 `..._CORRECTION_01.md`)。S `37deb6e` までの native・全 roster・validation・review の記録と失敗は全て保持し、新しい S に流用しない。原本は 23/88 のまま。
## 試験(`tests/economy/test_p05_funds_runtime.gd` に足した場面)
- 指摘1(F1・F5)`_test_actual_service_funds`: Runtime で、表の施設(`massage_room`、通常の雇用 `staff_petra` を DAY の配置)と、登録済みの裏の札の施設(`adult_private_room`、地下。rank 2・掘削・客用の通路と階段・職員用の通路と階段を通常の command で作る)を建てて開ける。自然に来る客が自分でサービスを選び、地下の個室へは地上から歩いて降りて、完了させる(客を置く fixture は使わない)。00:00 の締めで、massage_room の `SERVICE_CASH_SWEEP` は全て `PLAYER_CASH`、adult_private_room の分は全て `BACK_CASH`(他の行き先 0)、個室の支払が自然に来た客の財布からであること、`funds.revenue_front`・`revenue_back` がその日の行き先ごとの合計と一致すること、売上が表+裏であること、世界樹ランク税が両方の売上の合計の 10%(切り捨て)で払われたこと、裏金の残高と増減がその日の裏の売上と一致することを確かめる。行き先の選択を元に戻すとこの場面は失敗する。
- 指摘2(F2):
- 人件費: 同じ場面の2日目、締めの前に表金を 5 に下げ(台帳の fixture。下げるだけ)、本物の締めが実際の雇用の給与・維持費・税を払う。`funds.back_top_up` が(給与+維持費+税−5)、給与の未払い 0、表金 0、裏金がその分減ることを確かめる。
- 自動の仕入れ `_test_operation_top_ups_and_day_total`: 倉庫に自動の仕入れ(10 個)を設定し、表金 2・裏金 1,000 で時計を進める。`advance_auto_purchases` の本物の注文が 30 で買い、領収(`SupplyAutoPurchasePlans.receipt_from_ledger`)が検証され、28 が裏金からの補填(鍵 `finance.back_top_up.purchase.<注文の鍵>`)であることを確かめる。
- 旧暦の 00:00 の維持費 `_test_legacy_midnight_top_up`: `tests/mvp/test_upkeep_legacy_midnight.gd` と同じ作り方の旧暦の save(締めが 06:00)を読み、表金 0・裏金 500 で 00:00 を越える。00:00 の維持費 1 が裏金から補填され(鍵 `finance.back_top_up.midnight.1440`)、滞納 0、06:00 の締めの report がその補填を数えることを確かめる。
- 指摘3(F4): 同じ `_test_operation_top_ups_and_day_total` で、1,000 を借り(台帳の fixture。借入の発生源は無い)、返済の command(`repay_loan_principal`)で 200 を返す(表金 0 なので裏金で払う)。その日の締めの利息が返済の後の残高 800 の 1% の 8 で、払われることを確かめる。
- 指摘4(F3)`_test_tax_arrears_next_day`: 売上 200 の日に表裏の資金が無く、税 20 が未払いのまま繰り越す(日次の滞納と Operations の report に出る)。翌日の締めで古い 20 と新しい 10 を払い、税の未払いが 0 になることを確かめる。
- 各場面の最後に save の写しから戻し、そのまま一致することを確かめる。
## 日次の補填の定義(指摘5)と、保存済みの report の互換
- `funds.back_top_up` は、その営業日に記帳された全ての補填(操作の時の仕入れ・自動の仕入れ・返済、旧暦の 00:00 の維持費、締めの補填)の合計にした(`autoload/simulation_runtime.gd` の `_daily_fund_report`)。営業日の区切りは売上の集計と同じ(period_start 以上・締めの分より前)。締めの補填は鍵で数え、前日の締めの補填(period_start の分に記帳)は数えない。帳簿の画面の締めの欄「裏金から表金へ補填」はこの値を出す。
- report に定義の版 `fund_report_version: 2` を入れた。版の無い report は、S `7808ca7`・`37deb6e`(統合されていない)で保存された、締めの補填だけを数えた定義(版1)。読込の照合(`settlement.funds` と台帳から作り直した report の比較)は、保存された report が書かれた定義で比べる。版の無い report は版1の式でだけ受け、新しい式の値を持つ版の無い report、版2の印で締めの分だけの値の report、知らない版の report は拒む(緩めない)。A12(資金を分ける前の save)は report を持たないので、今までどおり(帳簿の記帳が無いことを確かめる)。版1の report は書かれた時のまま残し、画面は「(締めの時の分のみ)」と出す。
- 試験: 同じ日に注文・返済・締めの3つの補填がある save から、版1の形の report(印を外し、締めの分の値)を作って読めること、その値と表示が残ること、上の3つの不正な形が拒まれ何も変えないことを確かめる。
## 開示(指摘6 と、修正2で分かったこと)
- 今のゲームで裏金に入る売上の発生源は、登録済みの施設では `adult_private_room` だけ。`black_market`(trade。原子要件 income_trade のユニット売却に当たる)と `vip_private_salon` は拡張の定義で既定では登録されない。カジノ・脅迫金・ユニット売却の発生源は無い。原子要件 income_casino・income_blackmail・income_trade は未達のままで、この開示で完了にはしない。
- 定義が引けない施設の売上は、既存の挙動どおり表金に入る(裏の札の判定ができないため。`_apply_service_transaction` の注記)。定義の無い施設を扱う既存の経路は変えない。
- F1 の実際の売上の経路の試験で、`adult_private_room` の担当(役割 `service_attendant`)は Runtime の `register_staff` の command で登録した職員(fixture の職員。給与 0 の FREE_HIRE)。通常の雇用の候補にこの役割は無く、`_staff_role_matches_facility` にも代わりの役割は無いので、通常の Main(雇用と配置の画面)ではこの施設に職員を置けず、裏の売上を得られない。これは原 work の P09・P11 の `current_paid_role_compatibility_checkpoint.remaining_paid_role`(「adult_private_room/service_attendant has no canonical paid hire candidate」、2026-10-08、`work_packages.json`)に残っている原残件で、通常の Main での全施設の営業は未達。P05-02 では職員の候補・役割・給与・定義を作らず、P09・P11 の残件はこの開示で閉じない。
- `adult_private_room` の定義の `unlock_rank` 3・`research_requirements`(discreet_hospitality)・`opening_schedule`(夜だけ)は、今の Runtime では建設や営業の条件として使われていない(定義の検査だけ)。試験は rank 2(地下の開放)で建て、夜に営業させた。P05-02 では変えない。
- 補填の元にした表金の減少(台帳の fixture)と借入(台帳の fixture)は、残高を作るだけで、確かめる支払・補填・利息・税は本物の経路。
## 変えていないもの
- 登録の argv、`tools/run_p05_funds_tests.py`、狭い roster(8 suites+再開の2段、実 10 段)、試験の既存の期待値(`EXPECTATION_CHANGES.json` に足すものは無い)。
@@ -0,0 +1,24 @@
# P05-02 修正3: 通常の帳簿の画面の返済(A09)と、残りの試験と開示
- S `d65e3da9f33d399c598ad88296e4a9d88d847a7b` の whole-unit の独立レビュー(fresh、local_92520fb7)は changes_requested(medium 1・low 4)。F1〜F8 は証拠の上で満たすとされ、残りは下の指摘。正規の受付で `repair_pending`、commander の修正3(`COMMANDER_REPAIR_PLAN_P05_02_R03.md`、event 28、repairs 3)。S `d65e3da` までの native・全 roster・validation・review・容量の記録と失敗は全て保持し、新しい S に流用しない。原本は 23/88 のまま。
## 直したもの
- 指摘1(medium、F2 と横断条件「原本条件と UI 操作の一致」): 通常の帳簿の画面(`scripts/ui/operating_ledger_panel.gd`)の返済の上限・案内・拒否を、表金だけ(`cash`)から表金+裏金(`FinancePolicy.available_funds`)にした。`SimulationRuntime.get_loan_principal_report` に `back_cash` と `available_funds` を足した(`cash` は今までどおり表金)。案内は「表金 xG・裏金 yG(計 zG)から元本を返済します。表金で足りない分は裏金から補填します。」と出し、完済に足りない時は「完済には表金と裏金の合計が不足しています。」と出す。返済の command は今までどおり Runtime の `repay_loan_principal` で、表金から先に払い、残りを裏金からの補填と同じ1つの取引で記帳する(A09)。既存の試験が確かめる文言(「借入はありません」「受理済み」「実行できません」「…Gを返済」)は残した。
- 試験 `_test_ledger_repayment_uses_hidden_funds`: 通常の帳簿の画面の部品で、表金 20・裏金 300 の時に 200 の返済が押せ、1つの取引(裏金からの補填 180 → 元本 200)になること、残元本 800 の完済は表金+裏金 120 では押せず、500 の指定も押せず、押しても状態が変わらないこと、裏金が 820 になると完済が押せ、1つの取引(補填 800 → 元本 800)で完済し、表金 0・裏金 20・Gold が表金を写すこと、save から戻してそのまま一致すること。
- 指摘2(low): 税と利息の滞納も §3.8 の `arrears_total` に入るので、建設と投資の制限にも入る(開示)。締めの日次の記録の `arrears_total` に税・利息の滞納が入り、Operations の調整役の判定(`can_start_construction_or_investment`)が `ARREARS_REMAIN` で止める。その後の Runtime の判定(`_operations_construction_investment_gate`。`_operations_total_arrears` に `RANK_TAX_PAYABLE` と `LOAN_INTEREST_PAYABLE` の残高が入る)も、残高が残る間は `UNPAID_EXPENSES` で止める。試験 `_test_tax_arrears_next_day`: 税の滞納 20 がある日に建設の command が `ARREARS_REMAIN`(arrears 20)で拒まれ、翌日の締めで払った後に建てられる(表金から 20)こと(局所の確認で実際の理由が `ARREARS_REMAIN` だったので、レビューの指摘の `UNPAID_EXPENSES` ではなくそれを確かめる)。
- 指摘3(low): 試験を足した。表金 0・裏金 663 の時に建設(`guest_corridor`、20)が拒まれ、裏金が減らず、補填が増えないこと(建設は A09 の対象外。`_test_operation_top_ups_and_day_total`)。税の滞納が残る日に、帳簿の画面の締めの欄が「未払い 税 20G・利息 0G」、今日の欄が「(未払い 20G)」と出すこと(`_test_tax_arrears_next_day`)。罰金・event の支払など他の対象外の支払は、コードで表金だけから払うことを確かめたが試験は無い(開示)。
## 文言の補足(指摘4、low)
- 凍結の SPEC(契約の `spec_sha256`)と DB の契約は変えない。SPEC §3.5 の「元本の出入り(financing_principal_*)は今までどおり売上と経費に入れない」は、原本どおり次の意味で読む: 借入の元本の入金は売上に入れない(F4)。元本の返済は原本どおり日次の経費(`financing_principal_outflows`。`scripts/operations/operations_coordinator.gd` の `_net_profit` が日次の利益から引き、帳簿の画面は「元本返済(決算経費)」と出す。原子要件の借金返済の経費)。この単位はこの挙動を変えていない。
## 範囲の外で直していないもの(指摘5、low)
- 仕入れの画面 `scripts/ui/supply_management_panel.gd` は契約の allowed_paths の外なので、この単位では直さない。今の画面は「手持ち %dG」に表金だけを出し、見積の「購入後 %dG」は preview の `cash_after`(A09 で表金+裏金からの残り)を出すので、例えば表金 2・裏金 100 で 15 の仕入れでは「手持ち 2G」と「購入後 87G」が並ぶ。操作は A09 どおりに通る。
- 具体的な差分の案: 「手持ち %dG」を「表金 %dG・裏金 %dG」に分けて出し(`economy_service.get_balance(FinancePolicy.BACK_CASH)`)、「購入後 %dG」を「購入後(表金+裏金)%dG」とし、表金で足りない分を裏金から補うことを一言出す。
- 正規の扱い: この画面を範囲に含む別の登録(仕入れ・UI の単位、または P05-03 の登録で allowed_paths に入れる)で直す。それまでは未修正で、P05-02 の完了に数えない。
## 変えていないもの
- 登録の argv、`tools/run_p05_funds_tests.py`、狭い roster(8 suites+再開の2段、実 10 段)、試験の既存の期待値(`EXPECTATION_CHANGES.json` に足すものは無い)、凍結の SPEC、DB の契約。
@@ -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:
+37 -11
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,11 @@ 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():
# The day's every top-up; a report saved before the version mark counted the close's own only.
var top_up_scope := "" if funds.has(&"fund_report_version") or funds.has("fund_report_version") else "(締めの時の分のみ)"
_closed_label.text += "\nうち表の売上 %dG / 裏の売上 %dG\n世界樹ランク税 %dG(支払 %dG)/ 利息 %dG(支払 %dG)\n裏金から表金へ補填%s %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)), top_up_scope, 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"])):
@@ -206,7 +230,7 @@ func _build_repayment_controls(style_button: Callable) -> void:
loan_amount = LineEdit.new() loan_amount = LineEdit.new()
loan_amount.name = "LoanRepaymentAmount" loan_amount.name = "LoanRepaymentAmount"
loan_amount.placeholder_text = "返済する元本(整数G)" loan_amount.placeholder_text = "返済する元本(整数G)"
loan_amount.tooltip_text = "残元本と手持ちを超えない整数額を入力" loan_amount.tooltip_text = "残元本と表金・裏金の合計を超えない整数額を入力"
_loan_box.add_child(loan_amount) _loan_box.add_child(loan_amount)
loan_amount.text_changed.connect(func(_text: String) -> void: _refresh_repayment_controls()) loan_amount.text_changed.connect(func(_text: String) -> void: _refresh_repayment_controls())
loan_amount.text_submitted.connect(func(_text: String) -> void: submit_repayment(false)) loan_amount.text_submitted.connect(func(_text: String) -> void: submit_repayment(false))
@@ -265,12 +289,14 @@ func _refresh_repayment_controls() -> void:
var row := _loan_row(report) var row := _loan_row(report)
var remaining := int(row.get(&"outstanding_principal", 0)) var remaining := int(row.get(&"outstanding_principal", 0))
var cash := int(report.get(&"cash", 0)) var cash := int(report.get(&"cash", 0))
# A09: the front fund pays first and the hidden fund covers the rest in the same posting.
var funds := int(report.get(&"available_funds", cash))
var waiting := not (row.get(&"pending_command_ids", []) as Array).is_empty() var waiting := not (row.get(&"pending_command_ids", []) as Array).is_empty()
var allowed := bool(report.get(&"available", false)) and bool(report.get(&"ledger_valid", false)) and not bool(report.get(&"terminal", false)) and remaining > 0 and not waiting var allowed := bool(report.get(&"available", false)) and bool(report.get(&"ledger_valid", false)) and not bool(report.get(&"terminal", false)) and remaining > 0 and not waiting
var text := loan_amount.text.strip_edges() var text := loan_amount.text.strip_edges()
var amount := int(text) if text.is_valid_int() else 0 var amount := int(text) if text.is_valid_int() else 0
repay_button.disabled = not allowed or amount <= 0 or amount > remaining or amount > cash repay_button.disabled = not allowed or amount <= 0 or amount > remaining or amount > funds
repay_all_button.disabled = not allowed or remaining > cash repay_all_button.disabled = not allowed or remaining > funds
loan_amount.editable = allowed loan_amount.editable = allowed
if not bool(report.get(&"available", false)) or not bool(report.get(&"ledger_valid", false)): if not bool(report.get(&"available", false)) or not bool(report.get(&"ledger_valid", false)):
_loan_hint.text = "返済の帳簿を確認できません。" _loan_hint.text = "返済の帳簿を確認できません。"
@@ -279,13 +305,13 @@ func _refresh_repayment_controls() -> void:
elif loans.is_empty(): elif loans.is_empty():
_loan_hint.text = "借入はありません。" _loan_hint.text = "借入はありません。"
elif waiting: elif waiting:
_loan_hint.text = "返済を受理済みです。次の時間更新で実行し、手持ちと残元本を再確認します。" _loan_hint.text = "返済を受理済みです。次の時間更新で実行し、表金・裏金と残元本を再確認します。"
elif remaining == 0: elif remaining == 0:
_loan_hint.text = "この借入は完済しています。" _loan_hint.text = "この借入は完済しています。"
else: else:
_loan_hint.text = "手持ち %dGから元本を返済します。残元本 %dG。返済額は日次経費に含まれます。" % [cash, remaining] _loan_hint.text = "表金 %dG・裏金 %dG(計 %dG)から元本を返済します。表金で足りない分は裏金から補填します。残元本 %dG。返済額は日次経費に含まれます。" % [cash, funds - cash, funds, remaining]
if remaining > cash: _loan_hint.text += "\n完済には手持ちが不足しています。" if remaining > funds: _loan_hint.text += "\n完済には表金と裏金の合計が不足しています。"
if not text.is_empty() and repay_button.disabled: _loan_hint.text += "\n残元本と手持ちを超えない正の整数額を入力してください。" if not text.is_empty() and repay_button.disabled: _loan_hint.text += "\n残元本と表金・裏金の合計を超えない正の整数額を入力してください。"
func submit_repayment(full: bool = false) -> void: func submit_repayment(full: bool = false) -> void:
@@ -294,8 +320,8 @@ func submit_repayment(full: bool = false) -> void:
var remaining := int(row.get(&"outstanding_principal", 0)) var remaining := int(row.get(&"outstanding_principal", 0))
var text := loan_amount.text.strip_edges() var text := loan_amount.text.strip_edges()
var amount := remaining if full else (int(text) if text.is_valid_int() else 0) var amount := remaining if full else (int(text) if text.is_valid_int() else 0)
if not bool(report.get(&"ledger_valid", false)) or bool(report.get(&"terminal", false)) or row.is_empty() or not (row.get(&"pending_command_ids", []) as Array).is_empty() or amount <= 0 or amount > remaining or amount > int(report.get(&"cash", 0)): if not bool(report.get(&"ledger_valid", false)) or bool(report.get(&"terminal", false)) or row.is_empty() or not (row.get(&"pending_command_ids", []) as Array).is_empty() or amount <= 0 or amount > remaining or amount > int(report.get(&"available_funds", report.get(&"cash", 0))):
_set_repayment_result("返済できません。手持ち・残元本・返済待ちを確認してください。", false) _set_repayment_result("返済できません。表金・裏金・残元本・返済待ちを確認してください。", false)
_refresh_repayment_controls() _refresh_repayment_controls()
return return
var key := StringName("ui.loan.repay.%d" % int(report[&"next_command_id"])) var key := StringName("ui.loan.repay.%d" % int(report[&"next_command_id"]))
@@ -308,7 +334,7 @@ func submit_repayment(full: bool = false) -> void:
var after := _loan_row(_loan_report()) var after := _loan_row(_loan_report())
if result.is_success() and (after.get(&"pending_command_ids", []) as Array).has(int(result.command_id)): if result.is_success() and (after.get(&"pending_command_ids", []) as Array).has(int(result.command_id)):
_loan_pending_command = int(result.command_id) _loan_pending_command = int(result.command_id)
_set_repayment_result("返済を受理しました。実行時に手持ちと残元本を確認します。", true) _set_repayment_result("返済を受理しました。実行時に表金・裏金と残元本を確認します。", true)
else: else:
_show_repayment_result(result) _show_repayment_result(result)
_refresh_repayment_controls() _refresh_repayment_controls()
@@ -319,7 +345,7 @@ func _show_repayment_result(result: Variant) -> void:
_set_repayment_result("元本 %dGを返済しました。残元本 %dG。" % [int(result.details.get(&"paid_principal", 0)), int(result.details.get(&"outstanding_principal", 0))], true) _set_repayment_result("元本 %dGを返済しました。残元本 %dG。" % [int(result.details.get(&"paid_principal", 0)), int(result.details.get(&"outstanding_principal", 0))], true)
loan_amount.text = "" loan_amount.text = ""
else: else:
_set_repayment_result("返済を実行できませんでした。手持ちと残元本を確認してください。", false) _set_repayment_result("返済を実行できませんでした。表金・裏金と残元本を確認してください。", false)
func _on_repayment_completed(result: Variant) -> void: func _on_repayment_completed(result: Variant) -> void:
+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
+825
View File
@@ -0,0 +1,825 @@
extends SceneTree
const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd")
## P05-02: front (表金) and hidden (裏金) funds, the A09 payment rule, the A10 rank
## tax, the A11 loan interest, the three business day reconciliation and saves.
## The policy and reconciliation scenes use explicit ledger fixtures for sales,
## debt and drains, with the production Operations closes. The repair 2 scenes
## run the production paths themselves: built and staffed facilities whose guests
## complete their services, the automatic purchase, the repayment command and the
## legacy 00:00 upkeep; a fixture there only sets a balance (a loan, a drain), never
## the payment under test. The last check opens the ledger from the game's own Main
## (F7), so the runner gives this suite the ordinary Main audio cleanup proof
## (tools/regression/suite-policy.json).
const FRONT := &"PLAYER_CASH"
const BACK := &"BACK_CASH"
const LOAN := &"LOAN_LIABILITY:p05.fixture"
const FRONT_SALES := &"P05_FIXTURE_FRONT_SALES"
const BACK_SALES := &"P05_FIXTURE_BACK_SALES"
const PAYER := &"P05_FIXTURE_PAYER"
const SINK := &"P05_FIXTURE_SINK"
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("P05-02: front and hidden funds")
_test_policy_values()
_test_sale_fund_routing()
_test_three_business_days()
_test_resume_matches_uninterrupted()
_test_old_save_migration()
_test_repayment_top_up()
_test_purchase_top_up()
_test_upkeep_top_up()
_test_actual_service_funds()
_test_operation_top_ups_and_day_total()
_test_legacy_midnight_top_up()
_test_tax_arrears_next_day()
_test_ledger_repayment_uses_hidden_funds()
await _test_ledger_opened_from_main()
await process_frame
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _expect(condition: bool, message: String) -> void:
_checks += 1
if not condition:
_failures += 1
push_error(message)
func _new_fixture(seed: int) -> Dictionary:
var clock: Variant = load("res://autoload/game_clock.gd").new()
var state: Variant = load("res://autoload/game_state.gd").new()
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
clock.reset(1, 6, 0)
state.reset()
_expect(bool(runtime.initialize(clock, state, seed).get(&"valid", false)), "fixture Runtime initializes")
clock.set_paused(true)
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
_expect(bool(runtime.enable_production_operations().get(&"ok", false)), "fixture enables production Operations")
for account: StringName in [FRONT_SALES, BACK_SALES, PAYER, SINK]:
runtime.economy_service.register_account(account)
runtime.economy_service.register_account(LOAN, true)
# Guests' Gold enters the ledger only through explicit initialization.
_expect(runtime.economy_service.initialize_account(PAYER, 100000, clock.absolute_minute, &"p05.fixture.payer").is_success(), "fixture payer funded")
return {&"clock": clock, &"state": state, &"runtime": runtime}
func _free_fixture(fixture: Dictionary) -> void:
for key: StringName in [&"runtime", &"clock", &"state"]:
if fixture.get(key) != null:
fixture[key].free()
# Same accelerated boundary traversal as tests/mvp/test_runtime_operations_acceptance.gd:
# the fixtures hold no Facilities, Staff or logistics, only ledger postings, which
# are made on 30-minute marks.
func _advance(fixture: Dictionary, amount: int) -> void:
var clock: Variant = fixture[&"clock"]
var runtime: Variant = fixture[&"runtime"]
var advanced := 0
while advanced < amount:
# Every Operations boundary is on a 30-minute mark; each step ends on one.
var step := mini(30 - int(clock.absolute_minute) % 30, amount - advanced)
var target: int = clock.absolute_minute + step
clock.absolute_minute = target
clock.call("_sync_calendar_fields")
clock.snapshot_version += step
runtime.call("_advance_weather", target)
runtime.call("_advance_facility_upkeep", target)
runtime.call("_advance_operations_time", target)
_align_clocks(runtime, target)
advanced += step
# Same as tests/mvp/test_runtime_operations_acceptance.gd: the fixture owns no
# actors, cars or rides, so only the empty aggregates' clocks follow the minute.
func _align_clocks(runtime: Variant, minute: int) -> void:
for service: Variant in [runtime.inventory_service, runtime.facility_service, runtime.kitchen_production,
runtime.guest_service, runtime.staff_service, runtime.authority_service, runtime.defense_service,
runtime.construction_service, runtime.excavation_service]:
service.current_minute = minute
var transport_snapshot: Dictionary = runtime.elevators.create_snapshot()
if not (transport_snapshot[&"shafts"] as Array).is_empty() or not (transport_snapshot[&"trips"] as Array).is_empty():
_expect(false, "accelerated fixture cannot skip live elevator movement")
return
transport_snapshot[&"current_minute"] = minute
if not runtime.elevators.restore_snapshot(transport_snapshot):
_expect(false, "empty transport clock aligns through validated snapshot restore")
func _advance_to(fixture: Dictionary, minute: int) -> void:
_advance(fixture, minute - int(fixture[&"clock"].absolute_minute))
func _sale(fixture: Dictionary, revenue_account: StringName, fund: StringName, amount: int, key: String) -> bool:
var runtime: Variant = fixture[&"runtime"]
var minute: int = fixture[&"clock"].absolute_minute
var entries: Array = [
LedgerEntry.new().configure(&"", minute, revenue_account, PAYER, amount, &"SERVICE_SALE", &"p05.fixture"),
LedgerEntry.new().configure(&"", minute, fund, revenue_account, amount, &"SERVICE_CASH_SWEEP", &"p05.fixture"),
]
return runtime.economy_service.post(entries, StringName("p05.sale." + key)).is_success()
func _move(fixture: Dictionary, from: StringName, to: StringName, amount: int, category: StringName, key: String) -> bool:
var runtime: Variant = fixture[&"runtime"]
return runtime.economy_service.transfer(from, to, amount, fixture[&"clock"].absolute_minute, category,
&"p05.fixture", StringName("p05.move." + key)).is_success()
func _fund_delta(runtime: Variant, account: StringName, after_minute: int, through_minute: int) -> int:
var delta := 0
for entry: LedgerEntry in runtime.economy_service.get_entries():
if entry.absolute_minute <= after_minute or entry.absolute_minute > through_minute:
continue
if entry.debit_account == account:
delta += entry.amount
if entry.credit_account == account:
delta -= entry.amount
return delta
func _test_policy_values() -> void:
_expect(FinancePolicy.rank_tax(800) == 80 and FinancePolicy.rank_tax(109) == 10 and FinancePolicy.rank_tax(9) == 0, "A10 rank tax is 10% rounded down")
_expect(FinancePolicy.rank_tax(-5) == 0, "no tax on a negative day")
_expect(FinancePolicy.loan_interest(1000) == 10 and FinancePolicy.loan_interest(199) == 1 and FinancePolicy.loan_interest(99) == 0, "A11 interest is 1% per day rounded down")
func _test_sale_fund_routing() -> void:
var fixture := _new_fixture(5021)
var runtime: Variant = fixture[&"runtime"]
var back_ids: Array[StringName] = []
var front_ids: Array[StringName] = []
# Expansion definitions are read from their files; they are not registered by default.
for path: String in ["res://data/facilities/adult_private_room.tres", "res://data/facilities/expansion/black_market.tres",
"res://data/facilities/expansion/vip_private_salon.tres", "res://data/facilities/cafeteria.tres"]:
var definition: Variant = load(path)
if definition == null:
continue
if FinancePolicy.is_back_fund_service(definition.tags):
back_ids.append(definition.id)
else:
front_ids.append(definition.id)
_expect(back_ids.has(&"adult_private_room") and back_ids.has(&"black_market") and back_ids.has(&"vip_private_salon"), "adult, trade and private salon services earn hidden funds")
_expect(front_ids.has(&"cafeteria"), "dining earns front funds")
_expect(runtime.economy_service.has_account(BACK) and not runtime.economy_service.account_allows_negative(BACK), "the hidden fund is its own non-negative account")
_expect(runtime.economy_service.get_balance(BACK) == 0 and runtime.economy_service.get_balance(FRONT) == 5000, "a new game starts with every Gold in front funds")
_free_fixture(fixture)
func _test_three_business_days() -> void:
var fixture := _new_fixture(5022)
var runtime: Variant = fixture[&"runtime"]
var state: Variant = fixture[&"state"]
var coordinator: Variant = runtime.operations_coordinator
# Day 1: borrow 1000, front sale 300, hidden sale 500.
_advance_to(fixture, 420)
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "borrow"), "fixture principal borrowed")
_expect(_sale(fixture, FRONT_SALES, FRONT, 300, "d1.front") and _sale(fixture, BACK_SALES, BACK, 500, "d1.back"), "day 1 sales")
_advance_to(fixture, 1440)
var day1: Dictionary = coordinator.get_day_summary(1)
_expect(not day1.is_empty(), "day 1 closes at 00:00")
var totals1: Dictionary = day1.get(&"category_totals", {})
_expect(int(totals1.get(&"revenue", -1)) == 800, "day 1 sales are front + hidden")
_expect(int(totals1.get(&"taxes_and_interest", -1)) == 90, "day 1 rank tax 80 and interest 10")
_expect(int(totals1.get(&"financing_principal_inflows", -1)) == 1000, "principal stays financing, outside sales")
var funds1: Dictionary = day1.get(&"settlement", {}).get(&"funds", {})
_expect(int(funds1.get(&"revenue_front", -1)) == 300 and int(funds1.get(&"revenue_back", -1)) == 500, "day 1 fund report splits sales")
_expect(int(funds1.get(&"rank_tax", -1)) == 80 and int(funds1.get(&"rank_tax_paid", -1)) == 80, "day 1 rank tax paid in full")
_expect(int(funds1.get(&"loan_interest", -1)) == 10 and int(funds1.get(&"loan_interest_paid", -1)) == 10, "day 1 interest paid in full")
_expect(int(funds1.get(&"back_top_up", -1)) == 0, "front funds alone paid day 1")
_expect(runtime.economy_service.get_balance(FRONT) == 6210 and runtime.economy_service.get_balance(BACK) == 500, "day 1 balances")
_expect(state.get_resource(&"gold") == runtime.economy_service.get_balance(FRONT), "Gold mirrors front funds")
# The starting Gold is posted at 06:00 (minute 360); day 1 changes follow it.
_expect(_fund_delta(runtime, FRONT, 360, 1440) == 1210 and _fund_delta(runtime, BACK, 360, 1440) == 500, "day 1 fund changes equal the ledger")
# Day 2: front funds fall to 5, so 5 of the 10 interest comes from hidden funds.
_advance_to(fixture, 1440 + 600)
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", 6205, &"OPERATING_EXPENSE", "d2.cost"), "day 2 purchase-like cost")
_advance_to(fixture, 2880)
var funds2: Dictionary = coordinator.get_day_summary(2).get(&"settlement", {}).get(&"funds", {})
_expect(int(funds2.get(&"back_top_up", -1)) == 5, "A09: hidden funds cover the 5 front funds cannot pay")
_expect(int(funds2.get(&"loan_interest_paid", -1)) == 10 and int(funds2.get(&"rank_tax", -1)) == 0, "day 2 interest paid, no sales no tax")
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 495, "day 2 balances")
_expect(int(coordinator.get_day_summary(2)[&"category_totals"][&"revenue"]) == 0, "the top-up is not sales")
_expect(_fund_delta(runtime, FRONT, 1440, 2880) == -6210 and _fund_delta(runtime, BACK, 1440, 2880) == -5, "day 2 fund changes equal the ledger")
# Day 3: hidden funds are gone too, so the interest stays unpaid.
_advance_to(fixture, 2880 + 600)
_expect(_move(fixture, BACK, SINK, 495, &"TRANSFER", "d3.drain"), "day 3 hidden funds drained")
_advance_to(fixture, 4320)
var day3: Dictionary = coordinator.get_day_summary(3)
var funds3: Dictionary = day3.get(&"settlement", {}).get(&"funds", {})
_expect(int(funds3.get(&"loan_interest", -1)) == 10 and int(funds3.get(&"loan_interest_paid", -1)) == 0 and int(funds3.get(&"loan_interest_arrears", -1)) == 10, "A09: unpaid interest carries over")
_expect(int(day3[&"settlement"][&"arrears_total"]) == 10, "day 3 arrears include the interest")
_expect(int(runtime.get_operations_report().get(&"arrears_total", -1)) == 10, "Operations report sees the interest arrears")
_expect(int(day3[&"category_totals"][&"taxes_and_interest"]) == 10, "the unpaid interest is still the day's expense")
# Day 4: a front sale pays the older interest first.
_advance_to(fixture, 4320 + 600)
_expect(_sale(fixture, FRONT_SALES, FRONT, 100, "d4.front"), "day 4 sale")
_advance_to(fixture, 5760)
var funds4: Dictionary = coordinator.get_day_summary(4).get(&"settlement", {}).get(&"funds", {})
_expect(int(funds4.get(&"rank_tax", -1)) == 10 and int(funds4.get(&"rank_tax_paid", -1)) == 10, "day 4 tax paid")
_expect(int(funds4.get(&"loan_interest_paid", -1)) == 20 and int(funds4.get(&"loan_interest_arrears", -1)) == 0, "day 4 pays the older interest and the new one")
_expect(runtime.economy_service.get_balance(FRONT) == 70, "day 4 front balance")
_expect(runtime.economy_service.validate_ledger()[&"ok"], "the ledger reconciles after four closes")
_check_ledger_panel(runtime, fixture[&"clock"])
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "four closed days restore exactly")
_free_fixture(fixture)
func _scripted_days(fixture: Dictionary, restore_at: Array[int], through_files := false) -> Dictionary:
var runtime: Variant = fixture[&"runtime"]
# Postings and save/restore points in time order; the last minute closes day 3.
var steps: Array = [
[420, func() -> void: _move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "borrow")],
[450, func() -> void: _sale(fixture, BACK_SALES, BACK, 700, "d1.back")],
[1440, Callable()],
[2040, func() -> void: _move(fixture, FRONT, &"OPERATING_EXPENSE", 5900, &"OPERATING_EXPENSE", "d2.cost")],
[2400, Callable()],
[2880, Callable()],
[3510, func() -> void: _sale(fixture, FRONT_SALES, FRONT, 50, "d3.front")],
[4320, Callable()],
]
for step: Array in steps:
var minute := int(step[0])
_advance_to(fixture, minute)
if (step[1] as Callable).is_valid():
(step[1] as Callable).call()
if minute in restore_at and through_files:
# The ordinary save file path: write the save, then load it back.
var path := "user://p05_resume_%d.save" % minute
_expect(bool(runtime.save_to_path(path).get(&"ok", false)), "save file at minute %d" % minute)
_expect(bool(runtime.load_from_path(path).get(&"ok", false)), "load the save file at minute %d" % minute)
elif minute in restore_at:
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved), "restore at minute %d" % minute)
return runtime.create_snapshot()
func _test_resume_matches_uninterrupted() -> void:
var straight := _new_fixture(5023)
var resumed := _new_fixture(5023)
var a := _scripted_days(straight, [])
var b := _scripted_days(resumed, [1440, 2400, 2880])
_expect(var_to_bytes(a) == var_to_bytes(b), "saving mid-day and at 00:00 changes nothing: no double tax, interest or top-up")
var runtime: Variant = resumed[&"runtime"]
var keys := 0
for entry: LedgerEntry in runtime.economy_service.get_entries():
if entry.category in [FinancePolicy.RANK_TAX_CATEGORY, FinancePolicy.LOAN_INTEREST_CATEGORY]:
keys += 1
# Day 1: tax 70 and interest 10; day 2: interest only (no sales); day 3: tax 5 and interest 10.
_expect(keys == 5, "each tax and interest accrual is posted once (no duplicates)")
# Through save files the save and load events are new, so compare the funds side only.
var through_files := _new_fixture(5023)
var c := _scripted_days(through_files, [1440, 2400, 2880], true)
_expect(var_to_bytes(c[&"systems"][&"runtime"][&"economy"]) == var_to_bytes(a[&"systems"][&"runtime"][&"economy"]), "saving to files and loading them back leaves the ledger exactly as without saving")
var straight_days: Array = straight[&"runtime"].operations_coordinator.get_day_summaries()
var file_days: Array = through_files[&"runtime"].operations_coordinator.get_day_summaries()
# Operations history is persisted in a transport-safe form (StringName ids become
# String values), so compare as the Runtime's own restore crosslink does.
var runtime_a: Variant = straight[&"runtime"]
_expect(runtime_a.call("_strict_variants_equal", runtime_a.call("_operations_snapshot_comparison_value", straight_days),
runtime_a.call("_operations_snapshot_comparison_value", file_days)), "the daily closes, fund reports and arrears are the same through save files")
_free_fixture(straight)
_free_fixture(resumed)
_free_fixture(through_files)
func _test_old_save_migration() -> void:
var fixture := _new_fixture(5024)
var runtime: Variant = fixture[&"runtime"]
_advance_to(fixture, 420)
var current: Dictionary = runtime.create_snapshot()
var old := current.duplicate(true)
var economy: Dictionary = old[&"systems"][&"runtime"][&"economy"]
var kept: Array = []
for account: Dictionary in economy[&"accounts"]:
if String(account[&"account_id"]) not in ["BACK_CASH", "RANK_TAX_EXPENSE", "RANK_TAX_PAYABLE", "LOAN_INTEREST_EXPENSE", "LOAN_INTEREST_PAYABLE"]:
kept.append(account)
economy[&"accounts"] = kept
_expect(runtime.restore_snapshot(old), "A12: a save from before the split loads")
_expect(runtime.economy_service.get_balance(FRONT) == 5000 and runtime.economy_service.get_balance(BACK) == 0, "A12: every Gold is front funds, hidden funds start at 0")
_expect(var_to_bytes(runtime.create_snapshot()) == var_to_bytes(current), "A12: the migrated save equals a current save of the same game")
var partial := old.duplicate(true)
var partial_accounts: Array = partial[&"systems"][&"runtime"][&"economy"][&"accounts"]
partial_accounts.append({&"account_id": "RANK_TAX_PAYABLE", &"allow_negative": true, &"system": false})
var before: Dictionary = runtime.create_snapshot()
_expect(not runtime.restore_snapshot(partial) and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "a partial set of fund accounts is rejected and changes nothing")
_free_fixture(fixture)
func _test_repayment_top_up() -> void:
var fixture := _new_fixture(5025)
var runtime: Variant = fixture[&"runtime"]
_advance_to(fixture, 420)
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "borrow"), "fixture principal")
_expect(_sale(fixture, BACK_SALES, BACK, 300, "back") and _move(fixture, FRONT, &"OPERATING_EXPENSE", 5980, &"OPERATING_EXPENSE", "cost"), "front 20, hidden 300")
var receipts: Array = []
runtime.economy_service.transaction_posted.connect(func(id: StringName, _entries: Array) -> void: receipts.append(id))
var before: Dictionary = runtime.create_snapshot()
_expect(not runtime.repay_loan_principal(&"p05.fixture", 400, &"p05.repay.too_much").is_success() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "front + hidden short: repayment rejected, nothing changes")
var paid: CommandResult = runtime.repay_loan_principal(&"p05.fixture", 200, &"p05.repay")
_expect(paid.is_success() and int(paid.details.get(&"outstanding_principal", -1)) == 800, "repayment uses hidden funds for the rest")
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 120, "front 20 first, then 180 from hidden funds")
_expect(receipts.size() == 1, "payment and top-up are one transaction")
var entries: Array = runtime.economy_service.get_transaction_entries(StringName(paid.details[&"transaction_id"]))
_expect(entries.size() == 2 and entries[0].category == FinancePolicy.TOP_UP_CATEGORY and entries[1].category == &"LOAN_PRINCIPAL_REPAID", "top-up then principal")
_expect(fixture[&"state"].get_resource(&"gold") == 0, "Gold mirrors front funds after repayment")
_free_fixture(fixture)
func _test_purchase_top_up() -> void:
var clock: Variant = load("res://autoload/game_clock.gd").new()
var state: Variant = load("res://autoload/game_state.gd").new()
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
clock.reset(1, 6, 0)
state.reset()
_expect(bool(runtime.initialize(clock, state, 5026).get(&"valid", false)), "purchase fixture Runtime initializes")
clock.set_paused(true)
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
for row: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
_expect(runtime.submit_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "build %s" % row[0])
clock.advance_minutes(180)
var warehouse := &""
for facility: FacilityInstance in runtime.facility_service.get_instances():
if facility.definition_id == &"warehouse":
warehouse = facility.instance_id
_expect(not warehouse.is_empty(), "warehouse completes")
var fixture := {&"clock": clock, &"state": state, &"runtime": runtime}
for account: StringName in [BACK_SALES, PAYER]:
runtime.economy_service.register_account(account)
_expect(runtime.economy_service.initialize_account(PAYER, 1000, clock.absolute_minute, &"p05.purchase.payer").is_success(), "fixture payer")
var front: int = runtime.economy_service.get_balance(FRONT)
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", front - 2, &"OPERATING_EXPENSE", "purchase.drain") and _sale(fixture, BACK_SALES, BACK, 100, "purchase.back"), "front 2, hidden 100")
var preview: Dictionary = runtime.preview_supply_purchase(warehouse, SupplyCatalog.RAW_FOOD, 5)
_expect(bool(preview.get(&"ok", false)) and int(preview[&"total_cost"]) == 15 and int(preview[&"cash_after"]) == 87, "A09: the quote counts front + hidden funds")
var paid: CommandResult = runtime.submit_command(&"purchase_supplies", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 5})
_expect(paid.is_success(), "the purchase uses hidden funds for what front funds cannot pay")
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 87, "front 2 first, then 13 from hidden funds")
var purchase_entries: Array = runtime.economy_service.get_transaction_entries(StringName(paid.details.get(&"transaction_id", &"")))
_expect(purchase_entries.size() == 1 and purchase_entries[0].credit_account == FRONT and purchase_entries[0].amount == 15, "the purchase receipt keeps its single front-fund entry")
var top_ups := 0
for entry: LedgerEntry in runtime.economy_service.get_entries():
if entry.category == FinancePolicy.TOP_UP_CATEGORY:
top_ups += entry.amount
_expect(top_ups == 13, "one 13 Gold top-up from hidden funds")
_expect(state.get_resource(&"gold") == 0, "Gold mirrors front funds after the purchase")
# A refused command is still recorded as processed; the funds and stock must not move.
var economy_before: Dictionary = runtime.economy_service.create_snapshot()
var inventory_before: Dictionary = runtime.inventory_service.create_snapshot()
var refused: CommandResult = runtime.submit_command(&"purchase_supplies", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40})
_expect(not refused.is_success() and StringName(refused.details.get(&"reason", &"")) == &"INSUFFICIENT_FUNDS", "front + hidden short: purchase refused for funds")
_expect(var_to_bytes(economy_before) == var_to_bytes(runtime.economy_service.create_snapshot()) \
and var_to_bytes(inventory_before) == var_to_bytes(runtime.inventory_service.create_snapshot()), "the refused purchase moves no Gold and no stock")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "purchase with a top-up restores exactly")
runtime.free()
clock.free()
state.free()
func _test_upkeep_top_up() -> void:
var fixture := _new_fixture(5027)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
_expect(runtime.submit_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}).is_success(), "build a warehouse")
clock.advance_minutes(180)
_advance_to(fixture, 600)
var front: int = runtime.economy_service.get_balance(FRONT)
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", front, &"OPERATING_EXPENSE", "upkeep.drain") and _sale(fixture, BACK_SALES, BACK, 1000, "upkeep.back"), "front 0, hidden 1000")
_advance_to(fixture, 1440)
var summary: Dictionary = runtime.operations_coordinator.get_day_summary(1)
var maintenance := int(summary.get(&"category_totals", {}).get(&"maintenance", -1))
var funds: Dictionary = summary.get(&"settlement", {}).get(&"funds", {})
_expect(maintenance > 0, "the warehouse owes upkeep for day 1")
_expect(int(funds.get(&"back_top_up", -1)) == maintenance + int(funds.get(&"rank_tax", 0)), "A09: hidden funds cover the upkeep (and tax) front funds cannot pay")
_expect(runtime.upkeep_settlement.get_arrears_total() == 0 and int(summary[&"settlement"][&"arrears_total"]) == 0, "no upkeep arrears")
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 1000 - int(funds[&"back_top_up"]), "front 0, hidden paid the rest")
_free_fixture(fixture)
func _command(runtime: Variant, type: StringName, payload: Dictionary) -> bool:
var result: CommandResult = runtime.submit_command(type, payload)
if not result.is_success():
push_error("%s: %s %s" % [type, result.message, result.details.get(&"reason", &"")])
return result.is_success()
func _facility_of(runtime: Variant, definition_id: StringName) -> StringName:
for facility: FacilityInstance in runtime.facility_service.get_instances():
if facility.definition_id == definition_id:
return facility.instance_id
return &""
func _day_funds(runtime: Variant, day: int) -> Dictionary:
return runtime.operations_coordinator.get_day_summary(day).get(&"settlement", {}).get(&"funds", {})
func _closed_ledger_text(runtime: Variant, clock: Variant) -> String:
var panel := OperatingLedgerPanel.new()
root.add_child(panel)
panel.setup(runtime, clock)
panel.select_latest_close()
var text: String = panel._closed_label.text
panel.queue_free()
return text
# F1/F5 and F2 (wages) on the production paths. A front facility (massage_room,
# staffed by an ordinary hire on a DAY assignment) and the registered hidden-service
# facility (adult_private_room, underground, its service_attendant on a NIGHT
# assignment) are built and open; the arriving guests choose and complete the
# services themselves and the 00:00 closes settle the day.
func _test_actual_service_funds() -> void:
var fixture := _new_fixture(5031)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
var economy: Variant = runtime.economy_service
# Rank 2 opens the underground, by the original room-zone suite's own route.
_expect(_command(runtime, &"change_resources", {&"deltas": {&"gold": 5000, &"materials": 100}})
and _command(runtime, &"update_progression_metrics", {&"metrics": {&"reputation": 25}})
and _command(runtime, &"set_world_tree_rank", {&"rank": 2}), "rank 2 through the public commands")
for x: int in range(0, 4):
_expect(_command(runtime, &"start_excavation", {&"cell": Vector3i(x, -1, 0)}), "excavate (%d, -1)" % x)
clock.advance_minutes(60)
# Guests go down by a guest corridor and stairs, staff by a service corridor and stairs.
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(0, -1, 0)}), "underground guest corridor")
for x: int in range(1, 4):
_expect(_command(runtime, &"build_facility", {&"definition_id": &"service_corridor", &"origin": Vector3i(x, -1, 1)}), "underground service corridor (%d, -1)" % x)
clock.advance_minutes(30)
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(0, 0, 0)})
and _command(runtime, &"build_facility", {&"definition_id": &"service_stairs", &"origin": Vector3i(3, 0, 1)}), "stairs down for guests and staff")
clock.advance_minutes(30)
_expect(not runtime.path_service.find_path(Vector3i(-2, 0, 0), Vector3i(0, -1, 0)).is_empty(), "guests can walk down to the room")
_expect(_command(runtime, &"build_facility", {&"definition_id": &"adult_private_room", &"origin": Vector3i(1, -1, 0)})
and _command(runtime, &"build_facility", {&"definition_id": &"massage_room", &"origin": Vector3i(-6, 0, 0)}), "build the hidden-service and the front-service facility")
clock.advance_minutes(240)
var back_room := _facility_of(runtime, &"adult_private_room")
var front_room := _facility_of(runtime, &"massage_room")
_expect(not back_room.is_empty() and not front_room.is_empty(), "both facilities complete")
_expect(_command(runtime, &"hire_staff_candidate", {&"candidate_id": &"staff_petra", &"current_cell": Vector3i(-5, 0, 0)})
and _command(runtime, &"set_staff_work_assignment", {&"staff_id": &"staff_petra", &"facility_id": front_room, &"template_id": &"DAY"}), "an ordinary hire works the massage room by day")
# No hire candidate holds the room's service_attendant role (REPAIR_02.md), so it
# is registered through the Runtime's register_staff command.
_expect(_command(runtime, &"register_staff", {&"staff_id": &"p05_attendant", &"role_id": &"service_attendant", &"options": {
&"current_cell": Vector3i(3, 0, 1), &"state": StaffRuntime.State.OFF_DUTY,
&"permitted_task_tags": [&"service", &"work", &"adult_service"], &"equipment_tags": [&"uniform"],
&"role_tags": [&"service_attendant"], &"skills": {&"service": 75}}})
and _command(runtime, &"set_staff_work_assignment", {&"staff_id": &"p05_attendant", &"facility_id": back_room, &"template_id": &"NIGHT"}), "the room's attendant works by night")
clock.advance_minutes(721 - int(clock.absolute_minute))
_expect(runtime.facility_service.get_instance(front_room).state == FacilityInstance.State.OPEN, "the massage room opens with its day staff")
clock.advance_minutes(1141 - int(clock.absolute_minute))
_expect(runtime.facility_service.get_instance(back_room).state == FacilityInstance.State.OPEN, "the room opens with its night staff")
clock.advance_minutes(1440 - int(clock.absolute_minute))
# Day 1: every sale of the massage room went to front funds, of the room to hidden funds.
var front_sales := 0
var back_sales := 0
var misrouted := 0
var back_payers := {}
for entry: LedgerEntry in economy.get_entries():
if entry.category == &"SERVICE_SALE" and entry.source_entity_id == back_room and String(entry.credit_account).begins_with("GUEST_WALLET:guest-auto-"):
back_payers[entry.credit_account] = true
if entry.category != &"SERVICE_CASH_SWEEP" or entry.absolute_minute >= 1440:
continue
if entry.source_entity_id == front_room and entry.debit_account == FRONT:
front_sales += entry.amount
elif entry.source_entity_id == back_room and entry.debit_account == BACK:
back_sales += entry.amount
else:
misrouted += 1
_expect(front_sales > 0 and back_sales > 0 and misrouted == 0, "front service sales sweep to PLAYER_CASH, hidden ones to BACK_CASH (front %d, hidden %d)" % [front_sales, back_sales])
_expect(not back_payers.is_empty(), "arriving guests went down to the room and paid for its service themselves (%d guests)" % back_payers.size())
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
var funds1 := _day_funds(runtime, 1)
_expect(int(funds1.get(&"revenue_front", -1)) == front_sales and int(funds1.get(&"revenue_back", -1)) == back_sales, "the 00:00 close reports the day's sales by fund")
_expect(int(day1[&"category_totals"][&"revenue"]) == front_sales + back_sales, "sales are front + hidden")
_expect(int(funds1.get(&"rank_tax", -1)) == FinancePolicy.rank_tax(front_sales + back_sales) and int(funds1.get(&"rank_tax", -1)) == (front_sales + back_sales) / 10
and int(funds1.get(&"rank_tax_paid", -1)) == int(funds1[&"rank_tax"]), "A10: the close taxes 10% of both sales, paid")
_expect(int(funds1.get(&"back_top_up", -1)) == 0 and economy.get_balance(BACK) == back_sales and _fund_delta(runtime, BACK, 0, 1440) == back_sales, "hidden funds hold exactly the day's hidden sales")
# Day 2: the front fund is lowered to 5 by a ledger fixture before the close, so the
# close must pay the real wages, upkeep and tax with hidden funds.
clock.advance_minutes(1440 + 1430 - int(clock.absolute_minute))
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 5, &"OPERATING_EXPENSE", "service.d2.drain"), "front funds lowered to 5")
var back_before: int = economy.get_balance(BACK)
clock.advance_minutes(2880 - int(clock.absolute_minute))
var day2: Dictionary = runtime.operations_coordinator.get_day_summary(2)
var totals2: Dictionary = day2.get(&"category_totals", {})
var funds2 := _day_funds(runtime, 2)
var due := int(totals2.get(&"wages", 0)) + int(totals2.get(&"maintenance", 0)) + int(totals2.get(&"taxes_and_interest", 0))
_expect(int(totals2.get(&"wages", 0)) > 0 and int(funds2.get(&"revenue_back", 0)) > 0, "day 2 has real wages and hidden sales")
_expect(int(funds2.get(&"back_top_up", -1)) == due - 5, "A09: hidden funds cover the wages, upkeep and tax the 5 front funds cannot pay")
_expect(int(day2[&"settlement"].get(&"unpaid_after_total", -1)) == 0 and int(day2[&"settlement"][&"arrears_total"]) == 0, "the wages are paid in full, nothing is in arrears")
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == back_before - (due - 5), "front 0, hidden funds paid the rest")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the two service days restore exactly")
_free_fixture(fixture)
func _has_key(dictionary: Dictionary, key: String) -> bool:
return dictionary.has(StringName(key)) or dictionary.has(key)
func _set_key(dictionary: Dictionary, key: String, value: Variant) -> void:
if dictionary.has(key):
dictionary[key] = value
else:
dictionary[StringName(key)] = value
func _erase_key(dictionary: Dictionary, key: String) -> void:
dictionary.erase(StringName(key))
dictionary.erase(key)
# The saved fund report of a day inside a Runtime snapshot; changing it changes the snapshot.
func _saved_funds(snapshot: Dictionary, day: int) -> Dictionary:
for summary: Dictionary in snapshot[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"day_summaries"]:
if int(summary.get(&"day", summary.get("day", -1))) == day:
var settlement: Dictionary = summary.get(&"settlement", summary.get("settlement", {}))
return settlement.get(&"funds", settlement.get("funds", {}))
return {}
# F2 (automatic purchase), F4 (interest after a repayment) and the day's top-up
# total: the warehouse's automatic order and the repayment command run on their
# production paths with too little front funds, then the 00:00 close. A report
# saved before the definition mark is then loaded by its own definition.
func _test_operation_top_ups_and_day_total() -> void:
var fixture := _new_fixture(5032)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
var economy: Variant = runtime.economy_service
_expect(_command(runtime, &"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}), "build a warehouse")
clock.advance_minutes(180)
var warehouse := _facility_of(runtime, &"warehouse")
_expect(not warehouse.is_empty(), "the warehouse completes")
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "ops.borrow"), "fixture principal 1000")
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 2, &"OPERATING_EXPENSE", "ops.drain")
and _sale(fixture, BACK_SALES, BACK, 1000, "ops.back"), "front 2, hidden 1000")
_expect(_command(runtime, &"set_supply_auto_purchase", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 10}), "an automatic order for 10 raw food")
clock.advance_minutes(1)
var plan: Dictionary = runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD)
var receipt := SupplyAutoPurchasePlans.receipt_from_ledger(economy, warehouse, SupplyCatalog.RAW_FOOD, clock.absolute_minute, StringName(plan.get(&"last_transaction_id", &"")))
_expect(plan.get(&"last_result") == &"PURCHASED" and receipt == {&"item_id": String(SupplyCatalog.RAW_FOOD), &"quantity": 10, &"unit_price": 3, &"total_cost": 30}, "the automatic order buys 10 for 30 and its receipt verifies")
var order_key := SupplyAutoPurchasePlans.order_key(warehouse, SupplyCatalog.RAW_FOOD, clock.absolute_minute)
var order_top_up: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(StringName("finance.back_top_up.purchase." + String(order_key))))
_expect(order_top_up.size() == 1 and order_top_up[0].category == FinancePolicy.TOP_UP_CATEGORY and order_top_up[0].amount == 28, "A09: hidden funds cover the 28 the automatic order's front funds cannot pay")
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 972, "front 0, hidden 972 after the automatic order")
var repaid: CommandResult = runtime.submit_command(&"repay_loan_principal", {&"loan_id": &"p05.fixture", &"amount": 200}, &"player", &"p05.fixture", 50, -1, &"p05.ops.repay")
_expect(repaid.is_success() and int(repaid.details.get(&"outstanding_principal", -1)) == 800, "the repayment command pays 200 of the 1000")
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 772, "hidden funds paid the repayment")
clock.advance_minutes(1440 - int(clock.absolute_minute))
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
var funds := _day_funds(runtime, 1)
_expect(int(funds.get(&"loan_interest", -1)) == 8 and int(funds.get(&"loan_interest_paid", -1)) == 8, "A11: the close charges 1% of the 800 left after the repayment, 8, paid")
_expect(int(funds.get(&"rank_tax", -1)) == 100 and int(funds.get(&"rank_tax_paid", -1)) == 100, "the hidden sale's tax is paid")
var close_top_up := 0
for entry: LedgerEntry in economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(&"finance.back_top_up.day.00000001")):
close_top_up += entry.amount
var every_top_up := 0
for entry: LedgerEntry in economy.get_entries():
if entry.category == FinancePolicy.TOP_UP_CATEGORY:
every_top_up += entry.amount
_expect(close_top_up == 8 + 100 + int(day1[&"category_totals"][&"maintenance"]), "the close tops up the interest, tax and upkeep")
var total := 28 + 200 + close_top_up
_expect(int(funds.get(&"back_top_up", -1)) == total and every_top_up == total, "funds.back_top_up is the day's every top-up: the order, the repayment and the close")
_expect(int(funds.get(&"fund_report_version", -1)) == 2, "the report carries its definition version")
_expect(economy.get_balance(BACK) == 1000 - total and economy.get_balance(FRONT) == 0, "hidden funds fell by exactly the day's top-ups")
_expect(_closed_ledger_text(runtime, clock).contains("裏金から表金へ補填 %dG" % total), "the ledger's close shows the day's every top-up")
# Construction is outside A09: with no front funds it is refused and the hidden fund is not used.
var refused: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)})
var top_ups_after := 0
for entry: LedgerEntry in economy.get_entries():
if entry.category == FinancePolicy.TOP_UP_CATEGORY:
top_ups_after += entry.amount
_expect(not refused.is_success() and economy.get_balance(BACK) == 1000 - total and top_ups_after == total,
"A09 does not cover construction: refused with front 0, hidden funds untouched (%s)" % refused.details.get(&"reason", refused.message))
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the day with three top-ups restores exactly")
# A report saved before the mark (back_top_up = the close's own top-up only) loads by that definition.
var old := saved.duplicate(true)
var old_funds := _saved_funds(old, 1)
_expect(_has_key(old_funds, "fund_report_version"), "the saved report holds the mark")
_erase_key(old_funds, "fund_report_version")
_set_key(old_funds, "back_top_up", close_top_up)
_expect(runtime.restore_snapshot(old), "an unmarked report with the close-only top-up loads")
var loaded := _day_funds(runtime, 1)
_expect(int(loaded.get(&"back_top_up", -1)) == close_top_up and not _has_key(loaded, "fund_report_version"), "the loaded old report stays as it was saved")
_expect(_closed_ledger_text(runtime, clock).contains("裏金から表金へ補填(締めの時の分のみ) %dG" % close_top_up), "the ledger labels the old report's close-only top-up")
# No looser check: each definition accepts only its own value.
for case: Array in [[null, total, "an unmarked report with the day total"], [2, close_top_up, "a marked report with the close-only top-up"], [3, total, "an unknown mark"]]:
var bad := saved.duplicate(true)
var bad_funds := _saved_funds(bad, 1)
if case[0] == null:
_erase_key(bad_funds, "fund_report_version")
else:
_set_key(bad_funds, "fund_report_version", case[0])
_set_key(bad_funds, "back_top_up", case[1])
var before: Dictionary = runtime.create_snapshot()
_expect(not runtime.restore_snapshot(bad) and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "%s is rejected and changes nothing" % case[2])
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the current save loads again")
_free_fixture(fixture)
# F2 (legacy calendar): in a save from before the 00:00 close the upkeep is paid at
# calendar midnight and the financial day closes at 06:00 (as in
# tests/mvp/test_upkeep_legacy_midnight.gd). With no front funds the midnight upkeep
# is topped up from hidden funds, and that 06:00 day's report counts it.
func _test_legacy_midnight_top_up() -> void:
var fixture := _new_fixture(5033)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
var economy: Variant = runtime.economy_service
var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"guest_corridor")
var old_rate := definition.maintenance_gold
definition.maintenance_gold = 1440
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}), "build a corridor with upkeep")
runtime.operations_coordinator._legacy_calendar_last_day = 1
clock.advance_minutes(1079)
var legacy: Dictionary = runtime.create_snapshot()
var data: Dictionary = legacy[&"systems"][&"runtime"]
data[&"runtime_schema_version"] = 9
preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data)
data[&"operations"][&"coordinator"][&"schema_version"] = 3
data[&"operations"][&"coordinator"].erase(&"legacy_calendar_last_day")
data[&"operations"][&"coordinator"].erase(&"legacy_history_last_day")
data[&"operations"][&"coordinator"].erase(&"legacy_principal_profit_last_day")
_expect(runtime.restore_snapshot(legacy), "the legacy-calendar save loads at 23:59")
# The load replaces the Runtime's ledger service.
economy = runtime.economy_service
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT), &"OPERATING_EXPENSE", "legacy.drain")
and _sale(fixture, BACK_SALES, BACK, 500, "legacy.back"), "front 0, hidden 500")
clock.advance_minutes(1)
var midnight: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(&"finance.back_top_up.midnight.1440"))
_expect(midnight.size() == 1 and midnight[0].category == FinancePolicy.TOP_UP_CATEGORY and midnight[0].amount == 1, "A09: hidden funds cover the 00:00 upkeep front funds cannot pay")
_expect(economy.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1 and runtime.upkeep_settlement.get_arrears_total() == 0
and economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 499, "the midnight upkeep is paid, nothing in arrears")
clock.advance_minutes(360)
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
var funds := _day_funds(runtime, 1)
_expect(int(day1.get(&"closed_at", -1)) == 1800 and int(funds.get(&"rank_tax", -1)) == 50 and int(funds.get(&"rank_tax_paid", -1)) == 50, "the 06:00 close taxes the hidden sale, paid")
_expect(int(funds.get(&"back_top_up", -1)) == 51 and economy.get_balance(BACK) == 449, "the 06:00 day's report counts the midnight top-up and the close's")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the legacy day restores exactly")
definition.maintenance_gold = old_rate
_free_fixture(fixture)
# F3: the day's tax that front and hidden funds cannot pay stays payable, and the
# next close pays that older tax before the new one.
func _test_tax_arrears_next_day() -> void:
var fixture := _new_fixture(5034)
var runtime: Variant = fixture[&"runtime"]
var economy: Variant = runtime.economy_service
_advance_to(fixture, 420)
_expect(_sale(fixture, FRONT_SALES, FRONT, 200, "tax.d1") and _move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT), &"OPERATING_EXPENSE", "tax.d1.drain"), "day 1 sale 200, then no funds")
_advance_to(fixture, 1440)
var funds1 := _day_funds(runtime, 1)
_expect(int(funds1.get(&"rank_tax", -1)) == 20 and int(funds1.get(&"rank_tax_paid", -1)) == 0 and int(funds1.get(&"rank_tax_arrears", -1)) == 20, "A09: the unpaid tax carries over")
_expect(int(runtime.operations_coordinator.get_day_summary(1)[&"settlement"][&"arrears_total"]) == 20 and int(runtime.get_operations_report().get(&"arrears_total", -1)) == 20, "the arrears include the tax")
# F7 with arrears: the ledger shows the unpaid tax in the close and in today's cash section.
var panel := OperatingLedgerPanel.new()
root.add_child(panel)
panel.setup(runtime, fixture[&"clock"])
panel.select_latest_close()
_expect(panel._closed_label.text.contains("未払い 税 20G・利息 0G") and panel._cash_label.text.contains("(未払い 20G)"), "the ledger shows the unpaid tax")
panel.queue_free()
# The closed day's arrears_total includes the tax arrears, so the Operations gate holds
# construction and investment until they are paid.
var blocked: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)})
_expect(not blocked.is_success() and StringName(blocked.details.get(&"reason", &"")) == &"ARREARS_REMAIN" and int(blocked.details.get(&"arrears_total", -1)) == 20,
"tax arrears stop construction (ARREARS_REMAIN, arrears 20)")
_advance_to(fixture, 1440 + 600)
_expect(_sale(fixture, FRONT_SALES, FRONT, 100, "tax.d2"), "day 2 sale 100")
_advance_to(fixture, 2880)
var funds2 := _day_funds(runtime, 2)
_expect(int(funds2.get(&"rank_tax", -1)) == 10 and int(funds2.get(&"rank_tax_paid", -1)) == 30 and int(funds2.get(&"rank_tax_arrears", -1)) == 0, "day 2 pays the older 20 and the new 10")
_expect(economy.get_balance(FRONT) == 70 and economy.get_balance(FinancePolicy.RANK_TAX_PAYABLE) == 0, "front 70, no tax payable left")
var built: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)})
_expect(built.is_success() and economy.get_balance(FRONT) == 50, "once the tax is paid, construction is allowed again (20 from front funds)")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the arrears days restore exactly")
_free_fixture(fixture)
# F2 through the ordinary ledger: the panel's repayment controls cap by front + hidden
# funds (A09), and a specified or full repayment that front funds cannot pay is one
# transaction: the front fund pays first and the hidden fund covers the rest.
func _test_ledger_repayment_uses_hidden_funds() -> void:
var fixture := _new_fixture(5035)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
var economy: Variant = runtime.economy_service
_advance_to(fixture, 420)
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "panel.borrow"), "fixture principal 1000")
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 20, &"OPERATING_EXPENSE", "panel.drain")
and _sale(fixture, BACK_SALES, BACK, 300, "panel.back"), "front 20, hidden 300")
var panel := OperatingLedgerPanel.new()
root.add_child(panel)
panel.setup(runtime, clock)
panel.loan_amount.text = "200"
panel.loan_amount.text_changed.emit("200")
var hint: String = panel.get_node("LoanRepaymentHint").text
_expect(not panel.repay_button.disabled and hint.contains("表金 20G・裏金 300G(計 320G)") and hint.contains("裏金から補填"), "a 200 repayment over front 20 is offered with the hidden fund")
var key := StringName("ui.loan.repay.%d" % int(runtime.get_loan_principal_report()[&"next_command_id"]))
panel.repay_button.pressed.emit()
var entries: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(key))
_expect(entries.size() == 2 and entries[0].category == FinancePolicy.TOP_UP_CATEGORY and entries[0].amount == 180 and entries[1].category == &"LOAN_PRINCIPAL_REPAID" and entries[1].amount == 200,
"one transaction: 180 hidden top-up, then the 200 principal")
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 120 and -economy.get_balance(LOAN) == 800 and panel.get_node("LoanRepaymentResult").text.contains("200Gを返済"),
"front 20 first, hidden 180 for the rest; 800 left")
_expect(panel.repay_all_button.disabled and panel.get_node("LoanRepaymentHint").text.contains("完済には表金と裏金の合計が不足しています"), "full repayment of 800 waits: front + hidden is 120")
panel.loan_amount.text = "500"
panel.loan_amount.text_changed.emit("500")
_expect(panel.repay_button.disabled, "500 over front + hidden 120 is not offered")
var before: Dictionary = runtime.create_snapshot()
panel.submit_repayment(true)
panel.submit_repayment(false)
_expect(var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "pressed anyway, the unaffordable repayments change nothing")
_expect(_sale(fixture, BACK_SALES, BACK, 700, "panel.back2"), "hidden funds reach 820")
panel.refresh()
_expect(not panel.repay_all_button.disabled, "full repayment of 800 is offered with front 0 and hidden 820")
var full_key := StringName("ui.loan.repay.%d" % int(runtime.get_loan_principal_report()[&"next_command_id"]))
panel.repay_all_button.pressed.emit()
var full: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(full_key))
_expect(full.size() == 2 and full[0].category == FinancePolicy.TOP_UP_CATEGORY and full[0].amount == 800 and full[1].amount == 800,
"the full repayment is one transaction, all from hidden funds")
_expect(economy.get_balance(LOAN) == 0 and economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 20 and fixture[&"state"].get_resource(&"gold") == 0, "the loan is repaid; front 0, hidden 20, Gold mirrors front funds")
panel.queue_free()
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the panel repayments restore exactly")
_free_fixture(fixture)
# F7: the ordinary ledger panel reads both funds, the rank tax, the interest, the
# top-ups and their arrears from the same ledger and daily closes.
func _check_ledger_panel(runtime: Variant, clock: Variant) -> void:
var panel := OperatingLedgerPanel.new()
root.add_child(panel)
panel.setup(runtime, clock)
var report: Dictionary = panel.get_report()
_expect(int(report.get(&"cash", -1)) == runtime.economy_service.get_balance(FRONT)
and int(report.get(&"back_cash", -1)) == runtime.economy_service.get_balance(BACK), "the panel reports both funds")
_expect(int(report.get(&"finance_arrears", -1)) == 0, "the panel reports no tax or interest arrears after day 4")
var cash_text: String = panel._cash_label.text
_expect(cash_text.contains("裏金 %dG" % runtime.economy_service.get_balance(BACK)) and cash_text.contains("本日の税") and cash_text.contains("利息"), "the cash section shows the hidden fund, tax and interest")
panel.select_latest_close()
var close_text: String = panel._closed_label.text
_expect(close_text.contains("うち表の売上 100G / 裏の売上 0G") and close_text.contains("世界樹ランク税 10G(支払 10G)") \
and close_text.contains("利息 10G(支払 20G)") and close_text.contains("未払い 税 0G・利息 0G"), "the day 4 close shows sales by fund, tax, interest and arrears")
panel.queue_free()
# F7 through ordinary operation: after a real 00:00 close on the game's own
# Runtime and clock, Main's 帳簿 navigation button opens the ledger.
func _test_ledger_opened_from_main() -> void:
var runtime: Variant = root.get_node("SimulationRuntime")
var clock: Variant = root.get_node("GameClock")
var state: Variant = root.get_node("GameState")
clock.reset(1, 6, 0)
state.reset()
runtime.initialize(clock, state, 5028)
clock.set_paused(true)
root.size = Vector2i(1440, 900)
var main: Control = load("res://scenes/main/main.tscn").instantiate()
root.add_child(main)
await process_frame
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
for account: StringName in [FRONT_SALES, BACK_SALES, PAYER]:
runtime.economy_service.register_account(account)
runtime.economy_service.register_account(LOAN, true)
_expect(runtime.economy_service.initialize_account(PAYER, 100000, clock.absolute_minute, &"p05.fixture.payer").is_success(), "game Runtime fixture payer funded")
clock.advance_minutes(60)
var fixture := {&"clock": clock, &"state": state, &"runtime": runtime}
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "ui.borrow") and _sale(fixture, FRONT_SALES, FRONT, 300, "ui.front")
and _sale(fixture, BACK_SALES, BACK, 500, "ui.back"), "day 1 loan and sales on the game Runtime")
clock.advance_minutes(1440 - int(clock.absolute_minute))
_expect(not runtime.operations_coordinator.get_day_summary(1).is_empty(), "the normal clock closes day 1 at 00:00")
var ledger_button: Button = (main.get("navigation_buttons") as Dictionary)[&"ledger"]
ledger_button.pressed.emit()
var panel: OperatingLedgerPanel = main.get("operating_ledger_panel")
_expect(panel.visible and ledger_button.button_pressed, "the 帳簿 button opens the ledger")
var report: Dictionary = panel.get_report()
_expect(int(report.get(&"cash", -1)) == 6210 and int(report.get(&"back_cash", -1)) == 500, "the opened ledger reports front 6210 and hidden 500")
# The day 1 tax and interest are paid at the 00:00 that opens day 2.
var cash_text: String = panel._cash_label.text
_expect(cash_text.contains("裏金 500G(本日の入金 +0G / 表金へ補填 −0G)") and cash_text.contains("本日の税 80G / 利息 10G(未払い 0G)"), "the opened ledger shows the hidden fund, today's tax and interest")
var close_text: String = panel._closed_label.text
_expect(close_text.contains("うち表の売上 300G / 裏の売上 500G") and close_text.contains("世界樹ランク税 80G(支払 80G)/ 利息 10G(支払 10G)")
and close_text.contains("裏金から表金へ補填 0G / 未払い 税 0G・利息 0G"), "the opened ledger shows the day 1 close by fund with tax and interest")
var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
_expect(bool(cleanup.get(&"passed", false)), "ordinary Main release with audio cleanup")
@@ -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": "4c8f929b0de76198182dfaaef4be4bea7961faa9c31c01889497d6675e0c5def",
"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"
+613
View File
@@ -0,0 +1,613 @@
#!/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
output = args.output
# The registered validation passes the literal '{build_dir}/p05-funds-evidence'. The current Runner
# replaces only {python} in validation argv and gives the task's build directory as BUILD_OUTPUT_DIR.
if output.startswith('{build_dir}/'):
build = os.environ.get('BUILD_OUTPUT_DIR', '')
require(build and Path(build).is_absolute() and Path(build).is_dir() and not Path(build).is_symlink(),
'registered build directory from BUILD_OUTPUT_DIR')
output = build + output[len('{build_dir}'):]
out = plain_path(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)