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

This commit is contained in:
Agent Control Runner
2026-10-23 19:42:50 +09:00
parent 37deb6e877
commit d65e3da9f3
6 changed files with 370 additions and 11 deletions
+38 -4
View File
@@ -15,6 +15,8 @@ const STARTER_MANA_OUTPUT := 100
const STARTER_MANA_SOURCE := &"world_tree.mana_source"
const STARTER_MANA_BUS := &"world_tree.mana_bus"
const PLAYER_CASH_ACCOUNT := &"PLAYER_CASH"
## The settlement.funds report definition (see _daily_fund_report).
const FUND_REPORT_VERSION := 2
const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE"
const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE"
const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE"
@@ -3604,8 +3606,18 @@ func _operations_payroll_crosslinks_are_valid(
elif upkeep_debt != 0:
return false
var finance_arrears := 0
# A saved report is checked by the definition it was written with: without
# the mark it is version 1; any other mark is not a report this game wrote.
var fund_version := FUND_REPORT_VERSION
if summary_settlement.get(&"funds", null) is Dictionary:
var saved_funds: Dictionary = summary_settlement[&"funds"]
var mark: Variant = saved_funds.get(&"fund_report_version", saved_funds.get("fund_report_version", null))
if mark == null:
fund_version = 1
elif typeof(mark) != TYPE_INT or int(mark) != FUND_REPORT_VERSION:
return false
var expected_funds := _daily_fund_report(economy, day, coordinator.get_financial_period_start(day),
coordinator.get_daily_close_minute(day))
coordinator.get_daily_close_minute(day), fund_version)
if expected_funds.is_empty():
return false
if summary_settlement.has(&"funds"):
@@ -3776,9 +3788,15 @@ func _post_daily_finance_charge(day: int, boundary_minute: int, new_amount: int,
## 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:
## and a restored save produce the same report. Version 2 (marked in the report):
## back_top_up is every top-up of the business day, the operation-time purchases
## and repayments and the legacy 00:00 upkeep as well as the close's own. A report
## without the mark is version 1, written while back_top_up counted only the
## close's own top-up; a restore checks it by that definition, not a looser one.
func _daily_fund_report(economy: EconomyService, day: int, period_start: int, boundary_minute: int,
version := FUND_REPORT_VERSION) -> Dictionary:
if economy == null or day <= 0 or period_start < 0 or boundary_minute <= period_start \
or version not in [1, FUND_REPORT_VERSION]:
return {}
var report := {
&"revenue_front": 0, &"revenue_back": 0, &"back_top_up": 0,
@@ -3811,6 +3829,19 @@ func _daily_fund_report(economy: EconomyService, day: int, period_start: int, bo
report[StringName(String(field) + "_paid")] = int(report[StringName(String(field) + "_paid")]) + entry.amount
else:
return {}
if version == FUND_REPORT_VERSION:
# The closes are keyed; the previous day's close is posted at period_start.
var closes := {}
for close_day: int in [day - 1, day]:
var close := economy.get_transaction_id_for_idempotency_key(StringName("finance.back_top_up.day.%08d" % close_day))
if not close.is_empty():
closes[close] = true
for entry: LedgerEntry in economy.get_entries():
if entry.category == FinancePolicy.TOP_UP_CATEGORY and entry.absolute_minute >= period_start \
and entry.absolute_minute < boundary_minute and not closes.has(entry.transaction_id):
report[&"back_top_up"] = int(report[&"back_top_up"]) + entry.amount
postings = true
report[&"fund_report_version"] = FUND_REPORT_VERSION
report[&"rank_tax_arrears"] = _payable_arrears_through(economy, FinancePolicy.RANK_TAX_PAYABLE, boundary_minute)
report[&"loan_interest_arrears"] = _payable_arrears_through(economy, FinancePolicy.LOAN_INTEREST_PAYABLE, boundary_minute)
report[&"has_finance_postings"] = postings
@@ -12155,6 +12186,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)
# Only a registered definition with a hidden-service tag routes the sale to the
# hidden fund. A facility whose definition cannot be read keeps the existing
# front-fund behaviour (disclosed in execution/p05-02/REPAIR_02.md).
var sale_definition := definition_registry.get_facility(facility.definition_id)
var sale_fund := FinancePolicy.BACK_CASH \
if sale_definition != null and FinancePolicy.is_back_fund_service(sale_definition.tags) else PLAYER_CASH_ACCOUNT
@@ -3931,7 +3931,7 @@
"template": "PASS: <checks> checks",
"required": true,
"fixed_check_count_used": false,
"source_sha256_at_schema_audit": "38a90af176e88e83041af7b20324f1383f9f38cdf05fa452c6cf75b8e3bf5b95",
"source_sha256_at_schema_audit": "0dcef5fb731753e1a8f4269b445aa0df68e5428431e3422712c27056008edeba",
"authority": {
"path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md",
"scope": "new P05-02 suite marker; native execution pending"
@@ -0,0 +1,32 @@
# P05-02 修正2: 実際の経路での表裏の資金・補填・利息・税の滞納と、日次の補填の定義
- S `37deb6e877e60e8f19ae4fbc64382880cf7f75bb` の whole-unit の独立レビュー(r02)は changes_requested(指摘 1 high・2 medium・3 low)。正規の受付で `repair_pending`、commander の修正2(`COMMANDER_REPAIR_PLAN_P05_02_R02.md`、訂正の追記 `..._CORRECTION_01.md`)。S `37deb6e` までの native・全 roster・validation・review の記録と失敗は全て保持し、新しい S に流用しない。原本は 23/88 のまま。
## 試験(`tests/economy/test_p05_funds_runtime.gd` に足した場面)
- 指摘1(F1・F5)`_test_actual_service_funds`: Runtime で、表の施設(`massage_room`、通常の雇用 `staff_petra` を DAY の配置)と、登録済みの裏の札の施設(`adult_private_room`、地下。rank 2・掘削・客用の通路と階段・職員用の通路と階段を通常の command で作る)を建てて開ける。自然に来る客が自分でサービスを選び、地下の個室へは地上から歩いて降りて、完了させる(客を置く fixture は使わない)。00:00 の締めで、massage_room の `SERVICE_CASH_SWEEP` は全て `PLAYER_CASH`、adult_private_room の分は全て `BACK_CASH`(他の行き先 0)、個室の支払が自然に来た客の財布からであること、`funds.revenue_front`・`revenue_back` がその日の行き先ごとの合計と一致すること、売上が表+裏であること、世界樹ランク税が両方の売上の合計の 10%(切り捨て)で払われたこと、裏金の残高と増減がその日の裏の売上と一致することを確かめる。行き先の選択を元に戻すとこの場面は失敗する。
- 指摘2(F2):
- 人件費: 同じ場面の2日目、締めの前に表金を 5 に下げ(台帳の fixture。下げるだけ)、本物の締めが実際の雇用の給与・維持費・税を払う。`funds.back_top_up` が(給与+維持費+税−5)、給与の未払い 0、表金 0、裏金がその分減ることを確かめる。
- 自動の仕入れ `_test_operation_top_ups_and_day_total`: 倉庫に自動の仕入れ(10 個)を設定し、表金 2・裏金 1,000 で時計を進める。`advance_auto_purchases` の本物の注文が 30 で買い、領収(`SupplyAutoPurchasePlans.receipt_from_ledger`)が検証され、28 が裏金からの補填(鍵 `finance.back_top_up.purchase.<注文の鍵>`)であることを確かめる。
- 旧暦の 00:00 の維持費 `_test_legacy_midnight_top_up`: `tests/mvp/test_upkeep_legacy_midnight.gd` と同じ作り方の旧暦の save(締めが 06:00)を読み、表金 0・裏金 500 で 00:00 を越える。00:00 の維持費 1 が裏金から補填され(鍵 `finance.back_top_up.midnight.1440`)、滞納 0、06:00 の締めの report がその補填を数えることを確かめる。
- 指摘3(F4): 同じ `_test_operation_top_ups_and_day_total` で、1,000 を借り(台帳の fixture。借入の発生源は無い)、返済の command(`repay_loan_principal`)で 200 を返す(表金 0 なので裏金で払う)。その日の締めの利息が返済の後の残高 800 の 1% の 8 で、払われることを確かめる。
- 指摘4(F3)`_test_tax_arrears_next_day`: 売上 200 の日に表裏の資金が無く、税 20 が未払いのまま繰り越す(日次の滞納と Operations の report に出る)。翌日の締めで古い 20 と新しい 10 を払い、税の未払いが 0 になることを確かめる。
- 各場面の最後に save の写しから戻し、そのまま一致することを確かめる。
## 日次の補填の定義(指摘5)と、保存済みの report の互換
- `funds.back_top_up` は、その営業日に記帳された全ての補填(操作の時の仕入れ・自動の仕入れ・返済、旧暦の 00:00 の維持費、締めの補填)の合計にした(`autoload/simulation_runtime.gd` の `_daily_fund_report`)。営業日の区切りは売上の集計と同じ(period_start 以上・締めの分より前)。締めの補填は鍵で数え、前日の締めの補填(period_start の分に記帳)は数えない。帳簿の画面の締めの欄「裏金から表金へ補填」はこの値を出す。
- report に定義の版 `fund_report_version: 2` を入れた。版の無い report は、S `7808ca7`・`37deb6e`(統合されていない)で保存された、締めの補填だけを数えた定義(版1)。読込の照合(`settlement.funds` と台帳から作り直した report の比較)は、保存された report が書かれた定義で比べる。版の無い report は版1の式でだけ受け、新しい式の値を持つ版の無い report、版2の印で締めの分だけの値の report、知らない版の report は拒む(緩めない)。A12(資金を分ける前の save)は report を持たないので、今までどおり(帳簿の記帳が無いことを確かめる)。版1の report は書かれた時のまま残し、画面は「(締めの時の分のみ)」と出す。
- 試験: 同じ日に注文・返済・締めの3つの補填がある save から、版1の形の report(印を外し、締めの分の値)を作って読めること、その値と表示が残ること、上の3つの不正な形が拒まれ何も変えないことを確かめる。
## 開示(指摘6 と、修正2で分かったこと)
- 今のゲームで裏金に入る売上の発生源は、登録済みの施設では `adult_private_room` だけ。`black_market`(trade。原子要件 income_trade のユニット売却に当たる)と `vip_private_salon` は拡張の定義で既定では登録されない。カジノ・脅迫金・ユニット売却の発生源は無い。原子要件 income_casino・income_blackmail・income_trade は未達のままで、この開示で完了にはしない。
- 定義が引けない施設の売上は、既存の挙動どおり表金に入る(裏の札の判定ができないため。`_apply_service_transaction` の注記)。定義の無い施設を扱う既存の経路は変えない。
- F1 の実際の売上の経路の試験で、`adult_private_room` の担当(役割 `service_attendant`)は Runtime の `register_staff` の command で登録した職員(fixture の職員。給与 0 の FREE_HIRE)。通常の雇用の候補にこの役割は無く、`_staff_role_matches_facility` にも代わりの役割は無いので、通常の Main(雇用と配置の画面)ではこの施設に職員を置けず、裏の売上を得られない。これは原 work の P09・P11 の `current_paid_role_compatibility_checkpoint.remaining_paid_role`(「adult_private_room/service_attendant has no canonical paid hire candidate」、2026-10-08、`work_packages.json`)に残っている原残件で、通常の Main での全施設の営業は未達。P05-02 では職員の候補・役割・給与・定義を作らず、P09・P11 の残件はこの開示で閉じない。
- `adult_private_room` の定義の `unlock_rank` 3・`research_requirements`(discreet_hospitality)・`opening_schedule`(夜だけ)は、今の Runtime では建設や営業の条件として使われていない(定義の検査だけ)。試験は rank 2(地下の開放)で建て、夜に営業させた。P05-02 では変えない。
- 補填の元にした表金の減少(台帳の fixture)と借入(台帳の fixture)は、残高を作るだけで、確かめる支払・補填・利息・税は本物の経路。
## 変えていないもの
- 登録の argv、`tools/run_p05_funds_tests.py`、狭い roster(8 suites+再開の2段、実 10 段)、試験の既存の期待値(`EXPECTATION_CHANGES.json` に足すものは無い)。
+3 -1
View File
@@ -164,7 +164,9 @@ func refresh() -> void:
_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))]
# The day's every top-up; a report saved before the version mark counted the close's own only.
var top_up_scope := "" if funds.has(&"fund_report_version") or funds.has("fund_report_version") else "(締めの時の分のみ)"
_closed_label.text += "\nうち表の売上 %dG / 裏の売上 %dG\n世界樹ランク税 %dG(支払 %dG)/ 利息 %dG(支払 %dG)\n裏金から表金へ補填%s %dG / 未払い 税 %dG・利息 %dG" % [int(funds.get(&"revenue_front", 0)), int(funds.get(&"revenue_back", 0)), int(funds.get(&"rank_tax", 0)), int(funds.get(&"rank_tax_paid", 0)), int(funds.get(&"loan_interest", 0)), int(funds.get(&"loan_interest_paid", 0)), top_up_scope, int(funds.get(&"back_top_up", 0)), int(funds.get(&"rank_tax_arrears", 0)), int(funds.get(&"loan_interest_arrears", 0))]
if totals.has(&"financing_principal_inflows") or totals.has(&"financing_principal_outflows"):
_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"])):
+295 -4
View File
@@ -3,10 +3,14 @@ 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).
## The policy and reconciliation scenes use explicit ledger fixtures for sales,
## debt and drains, with the production Operations closes. The repair 2 scenes
## run the production paths themselves: built and staffed facilities whose guests
## complete their services, the automatic purchase, the repayment command and the
## legacy 00:00 upkeep; a fixture there only sets a balance (a loan, a drain), never
## the payment under test. The last check opens the ledger from the game's own Main
## (F7), so the runner gives this suite the ordinary Main audio cleanup proof
## (tools/regression/suite-policy.json).
const FRONT := &"PLAYER_CASH"
const BACK := &"BACK_CASH"
@@ -34,6 +38,10 @@ func _run() -> void:
_test_repayment_top_up()
_test_purchase_top_up()
_test_upkeep_top_up()
_test_actual_service_funds()
_test_operation_top_ups_and_day_total()
_test_legacy_midnight_top_up()
_test_tax_arrears_next_day()
await _test_ledger_opened_from_main()
await process_frame
if _failures == 0:
@@ -402,6 +410,289 @@ func _test_upkeep_top_up() -> void:
_free_fixture(fixture)
func _command(runtime: Variant, type: StringName, payload: Dictionary) -> bool:
var result: CommandResult = runtime.submit_command(type, payload)
if not result.is_success():
push_error("%s: %s %s" % [type, result.message, result.details.get(&"reason", &"")])
return result.is_success()
func _facility_of(runtime: Variant, definition_id: StringName) -> StringName:
for facility: FacilityInstance in runtime.facility_service.get_instances():
if facility.definition_id == definition_id:
return facility.instance_id
return &""
func _day_funds(runtime: Variant, day: int) -> Dictionary:
return runtime.operations_coordinator.get_day_summary(day).get(&"settlement", {}).get(&"funds", {})
func _closed_ledger_text(runtime: Variant, clock: Variant) -> String:
var panel := OperatingLedgerPanel.new()
root.add_child(panel)
panel.setup(runtime, clock)
panel.select_latest_close()
var text: String = panel._closed_label.text
panel.queue_free()
return text
# F1/F5 and F2 (wages) on the production paths. A front facility (massage_room,
# staffed by an ordinary hire on a DAY assignment) and the registered hidden-service
# facility (adult_private_room, underground, its service_attendant on a NIGHT
# assignment) are built and open; the arriving guests choose and complete the
# services themselves and the 00:00 closes settle the day.
func _test_actual_service_funds() -> void:
var fixture := _new_fixture(5031)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
var economy: Variant = runtime.economy_service
# Rank 2 opens the underground, by the original room-zone suite's own route.
_expect(_command(runtime, &"change_resources", {&"deltas": {&"gold": 5000, &"materials": 100}})
and _command(runtime, &"update_progression_metrics", {&"metrics": {&"reputation": 25}})
and _command(runtime, &"set_world_tree_rank", {&"rank": 2}), "rank 2 through the public commands")
for x: int in range(0, 4):
_expect(_command(runtime, &"start_excavation", {&"cell": Vector3i(x, -1, 0)}), "excavate (%d, -1)" % x)
clock.advance_minutes(60)
# Guests go down by a guest corridor and stairs, staff by a service corridor and stairs.
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(0, -1, 0)}), "underground guest corridor")
for x: int in range(1, 4):
_expect(_command(runtime, &"build_facility", {&"definition_id": &"service_corridor", &"origin": Vector3i(x, -1, 1)}), "underground service corridor (%d, -1)" % x)
clock.advance_minutes(30)
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(0, 0, 0)})
and _command(runtime, &"build_facility", {&"definition_id": &"service_stairs", &"origin": Vector3i(3, 0, 1)}), "stairs down for guests and staff")
clock.advance_minutes(30)
_expect(not runtime.path_service.find_path(Vector3i(-2, 0, 0), Vector3i(0, -1, 0)).is_empty(), "guests can walk down to the room")
_expect(_command(runtime, &"build_facility", {&"definition_id": &"adult_private_room", &"origin": Vector3i(1, -1, 0)})
and _command(runtime, &"build_facility", {&"definition_id": &"massage_room", &"origin": Vector3i(-6, 0, 0)}), "build the hidden-service and the front-service facility")
clock.advance_minutes(240)
var back_room := _facility_of(runtime, &"adult_private_room")
var front_room := _facility_of(runtime, &"massage_room")
_expect(not back_room.is_empty() and not front_room.is_empty(), "both facilities complete")
_expect(_command(runtime, &"hire_staff_candidate", {&"candidate_id": &"staff_petra", &"current_cell": Vector3i(-5, 0, 0)})
and _command(runtime, &"set_staff_work_assignment", {&"staff_id": &"staff_petra", &"facility_id": front_room, &"template_id": &"DAY"}), "an ordinary hire works the massage room by day")
# No hire candidate holds the room's service_attendant role (REPAIR_02.md), so it
# is registered through the Runtime's register_staff command.
_expect(_command(runtime, &"register_staff", {&"staff_id": &"p05_attendant", &"role_id": &"service_attendant", &"options": {
&"current_cell": Vector3i(3, 0, 1), &"state": StaffRuntime.State.OFF_DUTY,
&"permitted_task_tags": [&"service", &"work", &"adult_service"], &"equipment_tags": [&"uniform"],
&"role_tags": [&"service_attendant"], &"skills": {&"service": 75}}})
and _command(runtime, &"set_staff_work_assignment", {&"staff_id": &"p05_attendant", &"facility_id": back_room, &"template_id": &"NIGHT"}), "the room's attendant works by night")
clock.advance_minutes(721 - int(clock.absolute_minute))
_expect(runtime.facility_service.get_instance(front_room).state == FacilityInstance.State.OPEN, "the massage room opens with its day staff")
clock.advance_minutes(1141 - int(clock.absolute_minute))
_expect(runtime.facility_service.get_instance(back_room).state == FacilityInstance.State.OPEN, "the room opens with its night staff")
clock.advance_minutes(1440 - int(clock.absolute_minute))
# Day 1: every sale of the massage room went to front funds, of the room to hidden funds.
var front_sales := 0
var back_sales := 0
var misrouted := 0
var back_payers := {}
for entry: LedgerEntry in economy.get_entries():
if entry.category == &"SERVICE_SALE" and entry.source_entity_id == back_room and String(entry.credit_account).begins_with("GUEST_WALLET:guest-auto-"):
back_payers[entry.credit_account] = true
if entry.category != &"SERVICE_CASH_SWEEP" or entry.absolute_minute >= 1440:
continue
if entry.source_entity_id == front_room and entry.debit_account == FRONT:
front_sales += entry.amount
elif entry.source_entity_id == back_room and entry.debit_account == BACK:
back_sales += entry.amount
else:
misrouted += 1
_expect(front_sales > 0 and back_sales > 0 and misrouted == 0, "front service sales sweep to PLAYER_CASH, hidden ones to BACK_CASH (front %d, hidden %d)" % [front_sales, back_sales])
_expect(not back_payers.is_empty(), "arriving guests went down to the room and paid for its service themselves (%d guests)" % back_payers.size())
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
var funds1 := _day_funds(runtime, 1)
_expect(int(funds1.get(&"revenue_front", -1)) == front_sales and int(funds1.get(&"revenue_back", -1)) == back_sales, "the 00:00 close reports the day's sales by fund")
_expect(int(day1[&"category_totals"][&"revenue"]) == front_sales + back_sales, "sales are front + hidden")
_expect(int(funds1.get(&"rank_tax", -1)) == FinancePolicy.rank_tax(front_sales + back_sales) and int(funds1.get(&"rank_tax", -1)) == (front_sales + back_sales) / 10
and int(funds1.get(&"rank_tax_paid", -1)) == int(funds1[&"rank_tax"]), "A10: the close taxes 10% of both sales, paid")
_expect(int(funds1.get(&"back_top_up", -1)) == 0 and economy.get_balance(BACK) == back_sales and _fund_delta(runtime, BACK, 0, 1440) == back_sales, "hidden funds hold exactly the day's hidden sales")
# Day 2: the front fund is lowered to 5 by a ledger fixture before the close, so the
# close must pay the real wages, upkeep and tax with hidden funds.
clock.advance_minutes(1440 + 1430 - int(clock.absolute_minute))
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 5, &"OPERATING_EXPENSE", "service.d2.drain"), "front funds lowered to 5")
var back_before: int = economy.get_balance(BACK)
clock.advance_minutes(2880 - int(clock.absolute_minute))
var day2: Dictionary = runtime.operations_coordinator.get_day_summary(2)
var totals2: Dictionary = day2.get(&"category_totals", {})
var funds2 := _day_funds(runtime, 2)
var due := int(totals2.get(&"wages", 0)) + int(totals2.get(&"maintenance", 0)) + int(totals2.get(&"taxes_and_interest", 0))
_expect(int(totals2.get(&"wages", 0)) > 0 and int(funds2.get(&"revenue_back", 0)) > 0, "day 2 has real wages and hidden sales")
_expect(int(funds2.get(&"back_top_up", -1)) == due - 5, "A09: hidden funds cover the wages, upkeep and tax the 5 front funds cannot pay")
_expect(int(day2[&"settlement"].get(&"unpaid_after_total", -1)) == 0 and int(day2[&"settlement"][&"arrears_total"]) == 0, "the wages are paid in full, nothing is in arrears")
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == back_before - (due - 5), "front 0, hidden funds paid the rest")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the two service days restore exactly")
_free_fixture(fixture)
func _has_key(dictionary: Dictionary, key: String) -> bool:
return dictionary.has(StringName(key)) or dictionary.has(key)
func _set_key(dictionary: Dictionary, key: String, value: Variant) -> void:
if dictionary.has(key):
dictionary[key] = value
else:
dictionary[StringName(key)] = value
func _erase_key(dictionary: Dictionary, key: String) -> void:
dictionary.erase(StringName(key))
dictionary.erase(key)
# The saved fund report of a day inside a Runtime snapshot; changing it changes the snapshot.
func _saved_funds(snapshot: Dictionary, day: int) -> Dictionary:
for summary: Dictionary in snapshot[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"day_summaries"]:
if int(summary.get(&"day", summary.get("day", -1))) == day:
var settlement: Dictionary = summary.get(&"settlement", summary.get("settlement", {}))
return settlement.get(&"funds", settlement.get("funds", {}))
return {}
# F2 (automatic purchase), F4 (interest after a repayment) and the day's top-up
# total: the warehouse's automatic order and the repayment command run on their
# production paths with too little front funds, then the 00:00 close. A report
# saved before the definition mark is then loaded by its own definition.
func _test_operation_top_ups_and_day_total() -> void:
var fixture := _new_fixture(5032)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
var economy: Variant = runtime.economy_service
_expect(_command(runtime, &"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}), "build a warehouse")
clock.advance_minutes(180)
var warehouse := _facility_of(runtime, &"warehouse")
_expect(not warehouse.is_empty(), "the warehouse completes")
_expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "ops.borrow"), "fixture principal 1000")
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 2, &"OPERATING_EXPENSE", "ops.drain")
and _sale(fixture, BACK_SALES, BACK, 1000, "ops.back"), "front 2, hidden 1000")
_expect(_command(runtime, &"set_supply_auto_purchase", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 10}), "an automatic order for 10 raw food")
clock.advance_minutes(1)
var plan: Dictionary = runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD)
var receipt := SupplyAutoPurchasePlans.receipt_from_ledger(economy, warehouse, SupplyCatalog.RAW_FOOD, clock.absolute_minute, StringName(plan.get(&"last_transaction_id", &"")))
_expect(plan.get(&"last_result") == &"PURCHASED" and receipt == {&"item_id": String(SupplyCatalog.RAW_FOOD), &"quantity": 10, &"unit_price": 3, &"total_cost": 30}, "the automatic order buys 10 for 30 and its receipt verifies")
var order_key := SupplyAutoPurchasePlans.order_key(warehouse, SupplyCatalog.RAW_FOOD, clock.absolute_minute)
var order_top_up: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(StringName("finance.back_top_up.purchase." + String(order_key))))
_expect(order_top_up.size() == 1 and order_top_up[0].category == FinancePolicy.TOP_UP_CATEGORY and order_top_up[0].amount == 28, "A09: hidden funds cover the 28 the automatic order's front funds cannot pay")
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 972, "front 0, hidden 972 after the automatic order")
var repaid: CommandResult = runtime.submit_command(&"repay_loan_principal", {&"loan_id": &"p05.fixture", &"amount": 200}, &"player", &"p05.fixture", 50, -1, &"p05.ops.repay")
_expect(repaid.is_success() and int(repaid.details.get(&"outstanding_principal", -1)) == 800, "the repayment command pays 200 of the 1000")
_expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 772, "hidden funds paid the repayment")
clock.advance_minutes(1440 - int(clock.absolute_minute))
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
var funds := _day_funds(runtime, 1)
_expect(int(funds.get(&"loan_interest", -1)) == 8 and int(funds.get(&"loan_interest_paid", -1)) == 8, "A11: the close charges 1% of the 800 left after the repayment, 8, paid")
_expect(int(funds.get(&"rank_tax", -1)) == 100 and int(funds.get(&"rank_tax_paid", -1)) == 100, "the hidden sale's tax is paid")
var close_top_up := 0
for entry: LedgerEntry in economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(&"finance.back_top_up.day.00000001")):
close_top_up += entry.amount
var every_top_up := 0
for entry: LedgerEntry in economy.get_entries():
if entry.category == FinancePolicy.TOP_UP_CATEGORY:
every_top_up += entry.amount
_expect(close_top_up == 8 + 100 + int(day1[&"category_totals"][&"maintenance"]), "the close tops up the interest, tax and upkeep")
var total := 28 + 200 + close_top_up
_expect(int(funds.get(&"back_top_up", -1)) == total and every_top_up == total, "funds.back_top_up is the day's every top-up: the order, the repayment and the close")
_expect(int(funds.get(&"fund_report_version", -1)) == 2, "the report carries its definition version")
_expect(economy.get_balance(BACK) == 1000 - total and economy.get_balance(FRONT) == 0, "hidden funds fell by exactly the day's top-ups")
_expect(_closed_ledger_text(runtime, clock).contains("裏金から表金へ補填 %dG" % total), "the ledger's close shows the day's every top-up")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the day with three top-ups restores exactly")
# A report saved before the mark (back_top_up = the close's own top-up only) loads by that definition.
var old := saved.duplicate(true)
var old_funds := _saved_funds(old, 1)
_expect(_has_key(old_funds, "fund_report_version"), "the saved report holds the mark")
_erase_key(old_funds, "fund_report_version")
_set_key(old_funds, "back_top_up", close_top_up)
_expect(runtime.restore_snapshot(old), "an unmarked report with the close-only top-up loads")
var loaded := _day_funds(runtime, 1)
_expect(int(loaded.get(&"back_top_up", -1)) == close_top_up and not _has_key(loaded, "fund_report_version"), "the loaded old report stays as it was saved")
_expect(_closed_ledger_text(runtime, clock).contains("裏金から表金へ補填(締めの時の分のみ) %dG" % close_top_up), "the ledger labels the old report's close-only top-up")
# No looser check: each definition accepts only its own value.
for case: Array in [[null, total, "an unmarked report with the day total"], [2, close_top_up, "a marked report with the close-only top-up"], [3, total, "an unknown mark"]]:
var bad := saved.duplicate(true)
var bad_funds := _saved_funds(bad, 1)
if case[0] == null:
_erase_key(bad_funds, "fund_report_version")
else:
_set_key(bad_funds, "fund_report_version", case[0])
_set_key(bad_funds, "back_top_up", case[1])
var before: Dictionary = runtime.create_snapshot()
_expect(not runtime.restore_snapshot(bad) and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "%s is rejected and changes nothing" % case[2])
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the current save loads again")
_free_fixture(fixture)
# F2 (legacy calendar): in a save from before the 00:00 close the upkeep is paid at
# calendar midnight and the financial day closes at 06:00 (as in
# tests/mvp/test_upkeep_legacy_midnight.gd). With no front funds the midnight upkeep
# is topped up from hidden funds, and that 06:00 day's report counts it.
func _test_legacy_midnight_top_up() -> void:
var fixture := _new_fixture(5033)
var runtime: Variant = fixture[&"runtime"]
var clock: Variant = fixture[&"clock"]
var economy: Variant = runtime.economy_service
var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"guest_corridor")
var old_rate := definition.maintenance_gold
definition.maintenance_gold = 1440
_expect(_command(runtime, &"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}), "build a corridor with upkeep")
runtime.operations_coordinator._legacy_calendar_last_day = 1
clock.advance_minutes(1079)
var legacy: Dictionary = runtime.create_snapshot()
var data: Dictionary = legacy[&"systems"][&"runtime"]
data[&"runtime_schema_version"] = 9
preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data)
data[&"operations"][&"coordinator"][&"schema_version"] = 3
data[&"operations"][&"coordinator"].erase(&"legacy_calendar_last_day")
data[&"operations"][&"coordinator"].erase(&"legacy_history_last_day")
data[&"operations"][&"coordinator"].erase(&"legacy_principal_profit_last_day")
_expect(runtime.restore_snapshot(legacy), "the legacy-calendar save loads at 23:59")
# The load replaces the Runtime's ledger service.
economy = runtime.economy_service
_expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT), &"OPERATING_EXPENSE", "legacy.drain")
and _sale(fixture, BACK_SALES, BACK, 500, "legacy.back"), "front 0, hidden 500")
clock.advance_minutes(1)
var midnight: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(&"finance.back_top_up.midnight.1440"))
_expect(midnight.size() == 1 and midnight[0].category == FinancePolicy.TOP_UP_CATEGORY and midnight[0].amount == 1, "A09: hidden funds cover the 00:00 upkeep front funds cannot pay")
_expect(economy.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1 and runtime.upkeep_settlement.get_arrears_total() == 0
and economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 499, "the midnight upkeep is paid, nothing in arrears")
clock.advance_minutes(360)
var day1: Dictionary = runtime.operations_coordinator.get_day_summary(1)
var funds := _day_funds(runtime, 1)
_expect(int(day1.get(&"closed_at", -1)) == 1800 and int(funds.get(&"rank_tax", -1)) == 50 and int(funds.get(&"rank_tax_paid", -1)) == 50, "the 06:00 close taxes the hidden sale, paid")
_expect(int(funds.get(&"back_top_up", -1)) == 51 and economy.get_balance(BACK) == 449, "the 06:00 day's report counts the midnight top-up and the close's")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the legacy day restores exactly")
definition.maintenance_gold = old_rate
_free_fixture(fixture)
# F3: the day's tax that front and hidden funds cannot pay stays payable, and the
# next close pays that older tax before the new one.
func _test_tax_arrears_next_day() -> void:
var fixture := _new_fixture(5034)
var runtime: Variant = fixture[&"runtime"]
var economy: Variant = runtime.economy_service
_advance_to(fixture, 420)
_expect(_sale(fixture, FRONT_SALES, FRONT, 200, "tax.d1") and _move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT), &"OPERATING_EXPENSE", "tax.d1.drain"), "day 1 sale 200, then no funds")
_advance_to(fixture, 1440)
var funds1 := _day_funds(runtime, 1)
_expect(int(funds1.get(&"rank_tax", -1)) == 20 and int(funds1.get(&"rank_tax_paid", -1)) == 0 and int(funds1.get(&"rank_tax_arrears", -1)) == 20, "A09: the unpaid tax carries over")
_expect(int(runtime.operations_coordinator.get_day_summary(1)[&"settlement"][&"arrears_total"]) == 20 and int(runtime.get_operations_report().get(&"arrears_total", -1)) == 20, "the arrears include the tax")
_advance_to(fixture, 1440 + 600)
_expect(_sale(fixture, FRONT_SALES, FRONT, 100, "tax.d2"), "day 2 sale 100")
_advance_to(fixture, 2880)
var funds2 := _day_funds(runtime, 2)
_expect(int(funds2.get(&"rank_tax", -1)) == 10 and int(funds2.get(&"rank_tax_paid", -1)) == 30 and int(funds2.get(&"rank_tax_arrears", -1)) == 0, "day 2 pays the older 20 and the new 10")
_expect(economy.get_balance(FRONT) == 70 and economy.get_balance(FinancePolicy.RANK_TAX_PAYABLE) == 0, "front 70, no tax payable left")
var saved: Dictionary = runtime.create_snapshot()
_expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the arrears days restore exactly")
_free_fixture(fixture)
# 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:
+1 -1
View File
@@ -416,7 +416,7 @@
"new_audio_QA": false
},
"tests/economy/test_p05_funds_runtime.gd": {
"source_sha256": "38a90af176e88e83041af7b20324f1383f9f38cdf05fa452c6cf75b8e3bf5b95",
"source_sha256": "0dcef5fb731753e1a8f4269b445aa0df68e5428431e3422712c27056008edeba",
"ordinary_cleanup_prepared": true,
"ordinary_control_labels": [
"single"