class_name OperatingLedgerPanel extends VBoxContainer const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") signal status_requested(message: String, success: bool) ## Projection only: authoritative close and ledger entries own all amounts. var _initial_layer := 0 var _runtime: Variant var _clock: Variant var _cash_label: Label var _day_picker: OptionButton var _closed_label: Label var _facility_label: Label var _selected_day := -1 var _picker_days: Array[int] = [] var loan_option: OptionButton var loan_amount: LineEdit var repay_button: Button var repay_all_button: Button var _loan_box: VBoxContainer var _loan_hint: Label var _loan_result: Label var _selected_loan: StringName = &"" var _loan_pending_command := 0 var _loan_queue_instance := 0 var _audio_submitter := Callable() func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer := 0) -> void: _initial_layer = layer _runtime = runtime _clock = clock name = "OperatingLedgerPanel" add_theme_constant_override("separation", 8) var heading := Label.new() heading.text = "帳簿 · 00:00締め" heading.add_theme_color_override("font_color", Color("55371e")) LayerSkin.register_text(heading, _initial_layer) add_child(heading) _cash_label = _label() _build_repayment_controls(style_button) _day_picker = OptionButton.new() _day_picker.name = "LedgerDayPicker" if style_button.is_valid(): style_button.call(_day_picker) _day_picker.item_selected.connect(func(index: int) -> void: _selected_day = _day_picker.get_item_id(index) refresh()) add_child(_day_picker) _closed_label = _label() _facility_label = _label() if _runtime != null and _runtime.has_signal(&"command_completed"): _runtime.connect(&"command_completed", _on_repayment_completed) refresh() func select_latest_close() -> void: _selected_day = -1 _day_picker.get_popup().hide() refresh() func get_report() -> Dictionary: if _runtime == null: return {} var minute: int = _clock.absolute_minute var business_day := minute / 1440 + 1 var start := (business_day - 1) * 1440 if _runtime.operations_coordinator != null: business_day = _runtime.operations_coordinator.get_open_financial_day() start = _runtime.operations_coordinator.get_financial_period_start(business_day) var inflow := 0 var outflow := 0 var construction_outflow := 0 var wage_outflow := 0 var upkeep_outflow := 0 var back_inflow := 0 var back_top_up := 0 var rank_tax_outflow := 0 var loan_interest_outflow := 0 for entry: LedgerEntry in _runtime.economy_service.get_entries(): if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION": continue if entry.debit_account == FinancePolicy.BACK_CASH: back_inflow += entry.amount if entry.credit_account == FinancePolicy.BACK_CASH and entry.category == FinancePolicy.TOP_UP_CATEGORY: back_top_up += entry.amount if entry.debit_account == &"PLAYER_CASH": inflow += entry.amount if entry.credit_account == &"PLAYER_CASH": outflow += entry.amount if entry.category == &"CONSTRUCTION": construction_outflow += entry.amount elif entry.category == &"MAINTENANCE_PAYMENT": upkeep_outflow += entry.amount elif entry.category == &"WAGE": wage_outflow += entry.amount elif entry.category == FinancePolicy.RANK_TAX_PAYMENT_CATEGORY: rank_tax_outflow += entry.amount elif entry.category == FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY: loan_interest_outflow += entry.amount var summaries: Array[Dictionary] = [] if _runtime.operations_coordinator != null: summaries = _runtime.operations_coordinator.get_day_summaries() var selected: Dictionary = {} for summary: Dictionary in summaries: if int(summary.get(&"day", -1)) == _selected_day: selected = summary if selected.is_empty() and not summaries.is_empty(): selected = summaries.back() return { &"business_day": business_day, &"period_start": start, &"cash": _runtime.economy_service.get_balance(&"PLAYER_CASH"), &"cash_in": inflow, &"cash_out": outflow, &"cash_net": inflow - outflow, &"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow, &"upkeep_cash_out": upkeep_outflow, &"upkeep_arrears": _runtime.upkeep_settlement.get_arrears_total(), &"back_cash": _runtime.economy_service.get_balance(FinancePolicy.BACK_CASH), &"back_cash_in": back_inflow, &"back_top_up": back_top_up, &"rank_tax_cash_out": rank_tax_outflow, &"loan_interest_cash_out": loan_interest_outflow, &"finance_arrears": -_runtime.economy_service.get_balance(FinancePolicy.RANK_TAX_PAYABLE) - _runtime.economy_service.get_balance(FinancePolicy.LOAN_INTEREST_PAYABLE), &"closed_days": summaries, &"selected_close": selected.duplicate(true), } func refresh() -> void: if _runtime == null or _cash_label == null: return if _day_picker.get_popup().visible: return _refresh_repayment_controls() var report := get_report() _cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]] # 表金は上の手持ち。裏金は裏のサービスの売上で、表金で払えない分だけを補う(A09)。 _cash_label.text += "\n裏金 %dG(本日の入金 +%dG / 表金へ補填 −%dG)\n本日の税 %dG / 利息 %dG(未払い %dG)" % [report[&"back_cash"], report[&"back_cash_in"], report[&"back_top_up"], report[&"rank_tax_cash_out"], report[&"loan_interest_cash_out"], report[&"finance_arrears"]] var summary: Dictionary = report[&"selected_close"] if summary.is_empty(): if not _day_picker.disabled or _day_picker.item_count == 0: _day_picker.clear() _picker_days.clear() _day_picker.add_item("決算は翌日00:00に確定します") _day_picker.disabled = true _closed_label.text = "建築後の手持ち資金だけで営業成績は決まりません。宿泊などの売上と日々の費用をここで確認できます。" _facility_label.text = "" return _day_picker.disabled = false var days: Array[int] = [] for day: Dictionary in report[&"closed_days"]: days.append(int(day[&"day"])) if not _day_picker.get_popup().visible: if days != _picker_days: _day_picker.clear() _picker_days = days for day_number: int in days: _day_picker.add_item("第%d営業日 · 確定" % day_number, day_number) _day_picker.select(_picker_days.find(int(summary[&"day"]))) var totals: Dictionary = summary.get(&"category_totals", {}) var other := 0 for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]: other += int(totals.get(key, 0)) _closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n日次利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"] var funds: Dictionary = (summary.get(&"settlement", {}) as Dictionary).get(&"funds", {}) if not funds.is_empty(): # 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"])): _closed_label.text += "\n旧版の確定履歴:元本返済を利益から除外した計算を保持" var legacy: bool = _runtime.operations_coordinator.is_legacy_financial_history(int(summary[&"day"])) _closed_label.text += "\n%s %d回%s" % ["旧版の連続未払い" if legacy else "連続赤字", int(summary.get(&"consecutive_insolvency_count", 0)), "" if legacy else "(3回でゲーム終了)"] var close_minute := int(summary[&"closed_at"]) % 1440 _closed_label.text += "\n確定時刻 %02d:%02d" % [close_minute / 60, close_minute % 60] var lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"] var rows: Dictionary = summary.get(&"facility_profit_loss", {}) var ids := rows.keys() ids.sort() for id: Variant in ids: var facility: FacilityInstance = _runtime.facility_service.get_instance(StringName(id)) var label := String(id) if facility != null: var definition: FacilityDefinition = _runtime.definition_registry.get_facility(facility.definition_id) label = "%s (%d,%d)" % [definition.display_name if definition != null else facility.definition_id, facility.origin.x, facility.origin.y] else: # Corridors and stairs are completed construction entities without # service instances, but their ownership upkeep still appears here. var completed: Dictionary = _runtime.construction_service.get_facility(StringName(id)) if not completed.is_empty(): var definition: FacilityDefinition = _runtime.definition_registry.get_facility(completed[&"definition_id"]) var origin: Vector3i = completed[&"origin"] label = "%s (%d,%d)" % [definition.display_name if definition != null else completed[&"definition_id"], origin.x, origin.y] var row: Dictionary = rows[id] lines.append("%s\n%dG / %dG / %+dG" % [label, row.get(&"revenue", 0), row.get(&"expenses", 0), row.get(&"net_profit", 0)]) _facility_label.text = "\n".join(lines) if not rows.is_empty() else "施設別の記帳はまだありません。" func _label() -> Label: var label := Label.new() label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.add_theme_color_override("font_color", Color("55371e")) LayerSkin.register_text(label, _initial_layer) label.add_theme_font_size_override("font_size", 13) add_child(label) return label func configure_audio(submitter: Callable) -> void: _audio_submitter = submitter func _build_repayment_controls(style_button: Callable) -> void: _loan_hint = _label() _loan_hint.name = "LoanRepaymentHint" _loan_box = VBoxContainer.new() _loan_box.name = "LoanRepaymentControls" _loan_box.add_theme_constant_override("separation", 6) add_child(_loan_box) loan_option = OptionButton.new() loan_option.name = "LoanRepaymentOption" if style_button.is_valid(): style_button.call(loan_option) _loan_box.add_child(loan_option) loan_option.item_selected.connect(func(index: int) -> void: _selected_loan = StringName(loan_option.get_item_metadata(index)) loan_amount.text = "" _refresh_repayment_controls()) loan_amount = LineEdit.new() loan_amount.name = "LoanRepaymentAmount" loan_amount.placeholder_text = "返済する元本(整数G)" 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)) repay_button = Button.new() repay_button.name = "LoanRepaymentButton" repay_button.text = "指定額を返済" if style_button.is_valid(): style_button.call(repay_button) _loan_box.add_child(repay_button) repay_button.pressed.connect(submit_repayment.bind(false)) repay_all_button = Button.new() repay_all_button.name = "LoanRepayAllButton" repay_all_button.text = "元本を完済" if style_button.is_valid(): style_button.call(repay_all_button) _loan_box.add_child(repay_all_button) repay_all_button.pressed.connect(submit_repayment.bind(true)) _loan_result = _label() _loan_result.name = "LoanRepaymentResult" func _loan_report() -> Dictionary: if _runtime == null or not _runtime.has_method(&"get_loan_principal_report"): return {} return _runtime.call(&"get_loan_principal_report") func _loan_row(report: Dictionary) -> Dictionary: for row: Dictionary in report.get(&"loans", []): if StringName(row[&"loan_id"]) == _selected_loan: return row return {} func _refresh_repayment_controls() -> void: if loan_option == null: return var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null var instance: int = queue.get_instance_id() if queue != null else 0 if instance != _loan_queue_instance: _loan_queue_instance = instance _loan_pending_command = 0 _loan_result.text = "" loan_amount.text = "" var report := _loan_report() var loans: Array = report.get(&"loans", []) _loan_box.visible = not loans.is_empty() if not loan_option.get_popup().visible: loan_option.clear() var selection := -1 for index: int in loans.size(): var row: Dictionary = loans[index] var remaining := int(row[&"outstanding_principal"]) loan_option.add_item("借入 %d · %s" % [index + 1, "完済" if remaining == 0 else "残元本 %dG" % remaining]) loan_option.set_item_metadata(index, row[&"loan_id"]) if StringName(row[&"loan_id"]) == _selected_loan: selection = index if selection < 0 and not loans.is_empty(): selection = 0 _selected_loan = StringName(loans[0][&"loan_id"]) if selection >= 0: loan_option.select(selection) 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 > 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 = "返済の帳簿を確認できません。" elif bool(report.get(&"terminal", false)): _loan_hint.text = "運営は終了しました。借入の記録を確認できます。" elif loans.is_empty(): _loan_hint.text = "借入はありません。" elif waiting: _loan_hint.text = "返済を受理済みです。次の時間更新で実行し、表金・裏金と残元本を再確認します。" elif remaining == 0: _loan_hint.text = "この借入は完済しています。" else: _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: var report := _loan_report() var row := _loan_row(report) 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(&"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"])) var payload := {&"loan_id": _selected_loan, &"amount": amount} var result: Variant if _audio_submitter.is_valid(): result = _audio_submitter.call(&"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key) else: result = _runtime.call(&"submit_command", &"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key) 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) else: _show_repayment_result(result) _refresh_repayment_controls() func _show_repayment_result(result: Variant) -> void: if result.is_success(): _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) func _on_repayment_completed(result: Variant) -> void: if int(result.command_id) != _loan_pending_command: return _loan_pending_command = 0 _show_repayment_result(result) _refresh_repayment_controls() func _set_repayment_result(message: String, success: bool) -> void: _loan_result.text = message LayerSkin.register_status_text(_loan_result, success, LayerSkin.active_layer(self)) status_requested.emit(message, success)