diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 5d1b289..e3a3037 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -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 diff --git a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json index a20ce0e..4391a8e 100644 --- a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json +++ b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json @@ -3931,7 +3931,7 @@ "template": "PASS: 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" diff --git a/docs/plans/original-completion-2026-10-02/execution/p05-02/REPAIR_02.md b/docs/plans/original-completion-2026-10-02/execution/p05-02/REPAIR_02.md new file mode 100644 index 0000000..54e3e66 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p05-02/REPAIR_02.md @@ -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` に足すものは無い)。 diff --git a/scripts/ui/operating_ledger_panel.gd b/scripts/ui/operating_ledger_panel.gd index 3f23761..4827a47 100644 --- a/scripts/ui/operating_ledger_panel.gd +++ b/scripts/ui/operating_ledger_panel.gd @@ -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"])): diff --git a/tests/economy/test_p05_funds_runtime.gd b/tests/economy/test_p05_funds_runtime.gd index 3d9d771..5847ab8 100644 --- a/tests/economy/test_p05_funds_runtime.gd +++ b/tests/economy/test_p05_funds_runtime.gd @@ -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: diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index 41cb1a4..5cb5c35 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -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"