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