diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index e3a3037..5e42436 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -3677,7 +3677,10 @@ func get_loan_principal_report() -> Dictionary: loans.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"loan_id"]) < String(right[&"loan_id"])) var valid: bool = _state.get_resource(&"gold") == cash and (loans.is_empty() or _loan_principal_ledger_is_valid(economy_service)) var ended: bool = _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal() - return {&"available": true, &"ledger_valid": valid, &"terminal": ended, &"cash": cash, &"next_command_id": _next_command_id, &"loans": loans} + # A09: a repayment may use the hidden fund for what the front fund (cash) cannot pay. + return {&"available": true, &"ledger_valid": valid, &"terminal": ended, &"cash": cash, + &"back_cash": economy_service.get_balance(FinancePolicy.BACK_CASH), &"available_funds": FinancePolicy.available_funds(economy_service), + &"next_command_id": _next_command_id, &"loans": loans} ## Atomic principal payment against an already attested loan liability. 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 4391a8e..b1e084b 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": "0dcef5fb731753e1a8f4269b445aa0df68e5428431e3422712c27056008edeba", + "source_sha256_at_schema_audit": "4c8f929b0de76198182dfaaef4be4bea7961faa9c31c01889497d6675e0c5def", "authority": { "path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md", "scope": "new P05-02 suite marker; native execution pending" diff --git a/docs/plans/original-completion-2026-10-02/execution/p05-02/REPAIR_03.md b/docs/plans/original-completion-2026-10-02/execution/p05-02/REPAIR_03.md new file mode 100644 index 0000000..2e5511a --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p05-02/REPAIR_03.md @@ -0,0 +1,24 @@ +# P05-02 修正3: 通常の帳簿の画面の返済(A09)と、残りの試験と開示 + +- S `d65e3da9f33d399c598ad88296e4a9d88d847a7b` の whole-unit の独立レビュー(fresh、local_92520fb7)は changes_requested(medium 1・low 4)。F1〜F8 は証拠の上で満たすとされ、残りは下の指摘。正規の受付で `repair_pending`、commander の修正3(`COMMANDER_REPAIR_PLAN_P05_02_R03.md`、event 28、repairs 3)。S `d65e3da` までの native・全 roster・validation・review・容量の記録と失敗は全て保持し、新しい S に流用しない。原本は 23/88 のまま。 + +## 直したもの + +- 指摘1(medium、F2 と横断条件「原本条件と UI 操作の一致」): 通常の帳簿の画面(`scripts/ui/operating_ledger_panel.gd`)の返済の上限・案内・拒否を、表金だけ(`cash`)から表金+裏金(`FinancePolicy.available_funds`)にした。`SimulationRuntime.get_loan_principal_report` に `back_cash` と `available_funds` を足した(`cash` は今までどおり表金)。案内は「表金 xG・裏金 yG(計 zG)から元本を返済します。表金で足りない分は裏金から補填します。」と出し、完済に足りない時は「完済には表金と裏金の合計が不足しています。」と出す。返済の command は今までどおり Runtime の `repay_loan_principal` で、表金から先に払い、残りを裏金からの補填と同じ1つの取引で記帳する(A09)。既存の試験が確かめる文言(「借入はありません」「受理済み」「実行できません」「…Gを返済」)は残した。 + - 試験 `_test_ledger_repayment_uses_hidden_funds`: 通常の帳簿の画面の部品で、表金 20・裏金 300 の時に 200 の返済が押せ、1つの取引(裏金からの補填 180 → 元本 200)になること、残元本 800 の完済は表金+裏金 120 では押せず、500 の指定も押せず、押しても状態が変わらないこと、裏金が 820 になると完済が押せ、1つの取引(補填 800 → 元本 800)で完済し、表金 0・裏金 20・Gold が表金を写すこと、save から戻してそのまま一致すること。 +- 指摘2(low): 税と利息の滞納も §3.8 の `arrears_total` に入るので、建設と投資の制限にも入る(開示)。締めの日次の記録の `arrears_total` に税・利息の滞納が入り、Operations の調整役の判定(`can_start_construction_or_investment`)が `ARREARS_REMAIN` で止める。その後の Runtime の判定(`_operations_construction_investment_gate`。`_operations_total_arrears` に `RANK_TAX_PAYABLE` と `LOAN_INTEREST_PAYABLE` の残高が入る)も、残高が残る間は `UNPAID_EXPENSES` で止める。試験 `_test_tax_arrears_next_day`: 税の滞納 20 がある日に建設の command が `ARREARS_REMAIN`(arrears 20)で拒まれ、翌日の締めで払った後に建てられる(表金から 20)こと(局所の確認で実際の理由が `ARREARS_REMAIN` だったので、レビューの指摘の `UNPAID_EXPENSES` ではなくそれを確かめる)。 +- 指摘3(low): 試験を足した。表金 0・裏金 663 の時に建設(`guest_corridor`、20)が拒まれ、裏金が減らず、補填が増えないこと(建設は A09 の対象外。`_test_operation_top_ups_and_day_total`)。税の滞納が残る日に、帳簿の画面の締めの欄が「未払い 税 20G・利息 0G」、今日の欄が「(未払い 20G)」と出すこと(`_test_tax_arrears_next_day`)。罰金・event の支払など他の対象外の支払は、コードで表金だけから払うことを確かめたが試験は無い(開示)。 + +## 文言の補足(指摘4、low) + +- 凍結の SPEC(契約の `spec_sha256`)と DB の契約は変えない。SPEC §3.5 の「元本の出入り(financing_principal_*)は今までどおり売上と経費に入れない」は、原本どおり次の意味で読む: 借入の元本の入金は売上に入れない(F4)。元本の返済は原本どおり日次の経費(`financing_principal_outflows`。`scripts/operations/operations_coordinator.gd` の `_net_profit` が日次の利益から引き、帳簿の画面は「元本返済(決算経費)」と出す。原子要件の借金返済の経費)。この単位はこの挙動を変えていない。 + +## 範囲の外で直していないもの(指摘5、low) + +- 仕入れの画面 `scripts/ui/supply_management_panel.gd` は契約の allowed_paths の外なので、この単位では直さない。今の画面は「手持ち %dG」に表金だけを出し、見積の「購入後 %dG」は preview の `cash_after`(A09 で表金+裏金からの残り)を出すので、例えば表金 2・裏金 100 で 15 の仕入れでは「手持ち 2G」と「購入後 87G」が並ぶ。操作は A09 どおりに通る。 + - 具体的な差分の案: 「手持ち %dG」を「表金 %dG・裏金 %dG」に分けて出し(`economy_service.get_balance(FinancePolicy.BACK_CASH)`)、「購入後 %dG」を「購入後(表金+裏金)%dG」とし、表金で足りない分を裏金から補うことを一言出す。 + - 正規の扱い: この画面を範囲に含む別の登録(仕入れ・UI の単位、または P05-03 の登録で allowed_paths に入れる)で直す。それまでは未修正で、P05-02 の完了に数えない。 + +## 変えていないもの + +- 登録の argv、`tools/run_p05_funds_tests.py`、狭い roster(8 suites+再開の2段、実 10 段)、試験の既存の期待値(`EXPECTATION_CHANGES.json` に足すものは無い)、凍結の SPEC、DB の契約。 diff --git a/scripts/ui/operating_ledger_panel.gd b/scripts/ui/operating_ledger_panel.gd index 4827a47..3d62ae5 100644 --- a/scripts/ui/operating_ledger_panel.gd +++ b/scripts/ui/operating_ledger_panel.gd @@ -230,7 +230,7 @@ func _build_repayment_controls(style_button: Callable) -> void: loan_amount = LineEdit.new() loan_amount.name = "LoanRepaymentAmount" loan_amount.placeholder_text = "返済する元本(整数G)" - loan_amount.tooltip_text = "残元本と手持ちを超えない整数額を入力" + loan_amount.tooltip_text = "残元本と表金・裏金の合計を超えない整数額を入力" _loan_box.add_child(loan_amount) loan_amount.text_changed.connect(func(_text: String) -> void: _refresh_repayment_controls()) loan_amount.text_submitted.connect(func(_text: String) -> void: submit_repayment(false)) @@ -289,12 +289,14 @@ func _refresh_repayment_controls() -> void: var row := _loan_row(report) var remaining := int(row.get(&"outstanding_principal", 0)) var cash := int(report.get(&"cash", 0)) + # A09: the front fund pays first and the hidden fund covers the rest in the same posting. + var funds := int(report.get(&"available_funds", cash)) var waiting := not (row.get(&"pending_command_ids", []) as Array).is_empty() var allowed := bool(report.get(&"available", false)) and bool(report.get(&"ledger_valid", false)) and not bool(report.get(&"terminal", false)) and remaining > 0 and not waiting var text := loan_amount.text.strip_edges() var amount := int(text) if text.is_valid_int() else 0 - repay_button.disabled = not allowed or amount <= 0 or amount > remaining or amount > cash - repay_all_button.disabled = not allowed or remaining > cash + repay_button.disabled = not allowed or amount <= 0 or amount > remaining or amount > funds + repay_all_button.disabled = not allowed or remaining > funds loan_amount.editable = allowed if not bool(report.get(&"available", false)) or not bool(report.get(&"ledger_valid", false)): _loan_hint.text = "返済の帳簿を確認できません。" @@ -303,13 +305,13 @@ func _refresh_repayment_controls() -> void: elif loans.is_empty(): _loan_hint.text = "借入はありません。" elif waiting: - _loan_hint.text = "返済を受理済みです。次の時間更新で実行し、手持ちと残元本を再確認します。" + _loan_hint.text = "返済を受理済みです。次の時間更新で実行し、表金・裏金と残元本を再確認します。" elif remaining == 0: _loan_hint.text = "この借入は完済しています。" else: - _loan_hint.text = "手持ち %dGから元本を返済します。残元本 %dG。返済額は日次経費に含まれます。" % [cash, remaining] - if remaining > cash: _loan_hint.text += "\n完済には手持ちが不足しています。" - if not text.is_empty() and repay_button.disabled: _loan_hint.text += "\n残元本と手持ちを超えない正の整数額を入力してください。" + _loan_hint.text = "表金 %dG・裏金 %dG(計 %dG)から元本を返済します。表金で足りない分は裏金から補填します。残元本 %dG。返済額は日次経費に含まれます。" % [cash, funds - cash, funds, remaining] + if remaining > funds: _loan_hint.text += "\n完済には表金と裏金の合計が不足しています。" + if not text.is_empty() and repay_button.disabled: _loan_hint.text += "\n残元本と表金・裏金の合計を超えない正の整数額を入力してください。" func submit_repayment(full: bool = false) -> void: @@ -318,8 +320,8 @@ func submit_repayment(full: bool = false) -> void: var remaining := int(row.get(&"outstanding_principal", 0)) var text := loan_amount.text.strip_edges() var amount := remaining if full else (int(text) if text.is_valid_int() else 0) - if not bool(report.get(&"ledger_valid", false)) or bool(report.get(&"terminal", false)) or row.is_empty() or not (row.get(&"pending_command_ids", []) as Array).is_empty() or amount <= 0 or amount > remaining or amount > int(report.get(&"cash", 0)): - _set_repayment_result("返済できません。手持ち・残元本・返済待ちを確認してください。", false) + if not bool(report.get(&"ledger_valid", false)) or bool(report.get(&"terminal", false)) or row.is_empty() or not (row.get(&"pending_command_ids", []) as Array).is_empty() or amount <= 0 or amount > remaining or amount > int(report.get(&"available_funds", report.get(&"cash", 0))): + _set_repayment_result("返済できません。表金・裏金・残元本・返済待ちを確認してください。", false) _refresh_repayment_controls() return var key := StringName("ui.loan.repay.%d" % int(report[&"next_command_id"])) @@ -332,7 +334,7 @@ func submit_repayment(full: bool = false) -> void: var after := _loan_row(_loan_report()) if result.is_success() and (after.get(&"pending_command_ids", []) as Array).has(int(result.command_id)): _loan_pending_command = int(result.command_id) - _set_repayment_result("返済を受理しました。実行時に手持ちと残元本を確認します。", true) + _set_repayment_result("返済を受理しました。実行時に表金・裏金と残元本を確認します。", true) else: _show_repayment_result(result) _refresh_repayment_controls() @@ -343,7 +345,7 @@ func _show_repayment_result(result: Variant) -> void: _set_repayment_result("元本 %dGを返済しました。残元本 %dG。" % [int(result.details.get(&"paid_principal", 0)), int(result.details.get(&"outstanding_principal", 0))], true) loan_amount.text = "" else: - _set_repayment_result("返済を実行できませんでした。手持ちと残元本を確認してください。", false) + _set_repayment_result("返済を実行できませんでした。表金・裏金と残元本を確認してください。", false) func _on_repayment_completed(result: Variant) -> void: diff --git a/tests/economy/test_p05_funds_runtime.gd b/tests/economy/test_p05_funds_runtime.gd index 5847ab8..31680c4 100644 --- a/tests/economy/test_p05_funds_runtime.gd +++ b/tests/economy/test_p05_funds_runtime.gd @@ -42,6 +42,7 @@ func _run() -> void: _test_operation_top_ups_and_day_total() _test_legacy_midnight_top_up() _test_tax_arrears_next_day() + _test_ledger_repayment_uses_hidden_funds() await _test_ledger_opened_from_main() await process_frame if _failures == 0: @@ -599,6 +600,14 @@ func _test_operation_top_ups_and_day_total() -> void: _expect(int(funds.get(&"fund_report_version", -1)) == 2, "the report carries its definition version") _expect(economy.get_balance(BACK) == 1000 - total and economy.get_balance(FRONT) == 0, "hidden funds fell by exactly the day's top-ups") _expect(_closed_ledger_text(runtime, clock).contains("裏金から表金へ補填 %dG" % total), "the ledger's close shows the day's every top-up") + # Construction is outside A09: with no front funds it is refused and the hidden fund is not used. + var refused: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}) + var top_ups_after := 0 + for entry: LedgerEntry in economy.get_entries(): + if entry.category == FinancePolicy.TOP_UP_CATEGORY: + top_ups_after += entry.amount + _expect(not refused.is_success() and economy.get_balance(BACK) == 1000 - total and top_ups_after == total, + "A09 does not cover construction: refused with front 0, hidden funds untouched (%s)" % refused.details.get(&"reason", refused.message)) var saved: Dictionary = runtime.create_snapshot() _expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the day with three top-ups restores exactly") # A report saved before the mark (back_top_up = the close's own top-up only) loads by that definition. @@ -682,17 +691,80 @@ func _test_tax_arrears_next_day() -> void: var funds1 := _day_funds(runtime, 1) _expect(int(funds1.get(&"rank_tax", -1)) == 20 and int(funds1.get(&"rank_tax_paid", -1)) == 0 and int(funds1.get(&"rank_tax_arrears", -1)) == 20, "A09: the unpaid tax carries over") _expect(int(runtime.operations_coordinator.get_day_summary(1)[&"settlement"][&"arrears_total"]) == 20 and int(runtime.get_operations_report().get(&"arrears_total", -1)) == 20, "the arrears include the tax") + # F7 with arrears: the ledger shows the unpaid tax in the close and in today's cash section. + var panel := OperatingLedgerPanel.new() + root.add_child(panel) + panel.setup(runtime, fixture[&"clock"]) + panel.select_latest_close() + _expect(panel._closed_label.text.contains("未払い 税 20G・利息 0G") and panel._cash_label.text.contains("(未払い 20G)"), "the ledger shows the unpaid tax") + panel.queue_free() + # The closed day's arrears_total includes the tax arrears, so the Operations gate holds + # construction and investment until they are paid. + var blocked: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}) + _expect(not blocked.is_success() and StringName(blocked.details.get(&"reason", &"")) == &"ARREARS_REMAIN" and int(blocked.details.get(&"arrears_total", -1)) == 20, + "tax arrears stop construction (ARREARS_REMAIN, arrears 20)") _advance_to(fixture, 1440 + 600) _expect(_sale(fixture, FRONT_SALES, FRONT, 100, "tax.d2"), "day 2 sale 100") _advance_to(fixture, 2880) var funds2 := _day_funds(runtime, 2) _expect(int(funds2.get(&"rank_tax", -1)) == 10 and int(funds2.get(&"rank_tax_paid", -1)) == 30 and int(funds2.get(&"rank_tax_arrears", -1)) == 0, "day 2 pays the older 20 and the new 10") _expect(economy.get_balance(FRONT) == 70 and economy.get_balance(FinancePolicy.RANK_TAX_PAYABLE) == 0, "front 70, no tax payable left") + var built: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}) + _expect(built.is_success() and economy.get_balance(FRONT) == 50, "once the tax is paid, construction is allowed again (20 from front funds)") var saved: Dictionary = runtime.create_snapshot() _expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the arrears days restore exactly") _free_fixture(fixture) +# F2 through the ordinary ledger: the panel's repayment controls cap by front + hidden +# funds (A09), and a specified or full repayment that front funds cannot pay is one +# transaction: the front fund pays first and the hidden fund covers the rest. +func _test_ledger_repayment_uses_hidden_funds() -> void: + var fixture := _new_fixture(5035) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + var economy: Variant = runtime.economy_service + _advance_to(fixture, 420) + _expect(_move(fixture, LOAN, FRONT, 1000, &"LOAN_PRINCIPAL_BORROWED", "panel.borrow"), "fixture principal 1000") + _expect(_move(fixture, FRONT, &"OPERATING_EXPENSE", economy.get_balance(FRONT) - 20, &"OPERATING_EXPENSE", "panel.drain") + and _sale(fixture, BACK_SALES, BACK, 300, "panel.back"), "front 20, hidden 300") + var panel := OperatingLedgerPanel.new() + root.add_child(panel) + panel.setup(runtime, clock) + panel.loan_amount.text = "200" + panel.loan_amount.text_changed.emit("200") + var hint: String = panel.get_node("LoanRepaymentHint").text + _expect(not panel.repay_button.disabled and hint.contains("表金 20G・裏金 300G(計 320G)") and hint.contains("裏金から補填"), "a 200 repayment over front 20 is offered with the hidden fund") + var key := StringName("ui.loan.repay.%d" % int(runtime.get_loan_principal_report()[&"next_command_id"])) + panel.repay_button.pressed.emit() + var entries: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(key)) + _expect(entries.size() == 2 and entries[0].category == FinancePolicy.TOP_UP_CATEGORY and entries[0].amount == 180 and entries[1].category == &"LOAN_PRINCIPAL_REPAID" and entries[1].amount == 200, + "one transaction: 180 hidden top-up, then the 200 principal") + _expect(economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 120 and -economy.get_balance(LOAN) == 800 and panel.get_node("LoanRepaymentResult").text.contains("200Gを返済"), + "front 20 first, hidden 180 for the rest; 800 left") + _expect(panel.repay_all_button.disabled and panel.get_node("LoanRepaymentHint").text.contains("完済には表金と裏金の合計が不足しています"), "full repayment of 800 waits: front + hidden is 120") + panel.loan_amount.text = "500" + panel.loan_amount.text_changed.emit("500") + _expect(panel.repay_button.disabled, "500 over front + hidden 120 is not offered") + var before: Dictionary = runtime.create_snapshot() + panel.submit_repayment(true) + panel.submit_repayment(false) + _expect(var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "pressed anyway, the unaffordable repayments change nothing") + _expect(_sale(fixture, BACK_SALES, BACK, 700, "panel.back2"), "hidden funds reach 820") + panel.refresh() + _expect(not panel.repay_all_button.disabled, "full repayment of 800 is offered with front 0 and hidden 820") + var full_key := StringName("ui.loan.repay.%d" % int(runtime.get_loan_principal_report()[&"next_command_id"])) + panel.repay_all_button.pressed.emit() + var full: Array = economy.get_transaction_entries(economy.get_transaction_id_for_idempotency_key(full_key)) + _expect(full.size() == 2 and full[0].category == FinancePolicy.TOP_UP_CATEGORY and full[0].amount == 800 and full[1].amount == 800, + "the full repayment is one transaction, all from hidden funds") + _expect(economy.get_balance(LOAN) == 0 and economy.get_balance(FRONT) == 0 and economy.get_balance(BACK) == 20 and fixture[&"state"].get_resource(&"gold") == 0, "the loan is repaid; front 0, hidden 20, Gold mirrors front funds") + panel.queue_free() + var saved: Dictionary = runtime.create_snapshot() + _expect(runtime.restore_snapshot(saved) and var_to_bytes(saved) == var_to_bytes(runtime.create_snapshot()), "the panel repayments restore exactly") + _free_fixture(fixture) + + # F7: the ordinary ledger panel reads both funds, the rank tax, the interest, the # top-ups and their arrears from the same ledger and daily closes. func _check_ledger_panel(runtime: Variant, clock: Variant) -> void: diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index 5cb5c35..7ddba83 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": "0dcef5fb731753e1a8f4269b445aa0df68e5428431e3422712c27056008edeba", + "source_sha256": "4c8f929b0de76198182dfaaef4be4bea7961faa9c31c01889497d6675e0c5def", "ordinary_cleanup_prepared": true, "ordinary_control_labels": [ "single"