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

This commit is contained in:
Agent Control Runner
2026-10-23 16:02:54 +09:00
parent 613b9886da
commit 7808ca76db
20 changed files with 1906 additions and 17 deletions
+211 -13
View File
@@ -909,6 +909,9 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
return false
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
),
]