Files
TheTree_mock/scripts/ui/business_management_panel.gd
T
Takeshi 285528b7a3 Expose pending service pricing in facility UI
Show normal usage fees separately from the unspecified desire-match surcharge. Preserve free reception and omit guest pricing from warehouse and cleaning rooms.

Validation: 155 suites / 12,310 checks; focused 700 checks; strict state and event equivalence; 16 normal and compact FRONT/BACK captures.
2026-10-01 02:43:39 +09:00

418 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name BusinessManagementPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Policy drafts are local; accepted commands and read models belong to Runtime.
signal status_requested(message: String, success: bool)
signal action_requested(action: StringName)
const KEY_PREFIX := "ui.business."
const COMMAND: StringName = &"set_facility_policy"
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与と施設維持費は発生します。"
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与と施設維持費も発生します。"
const SERVICE_ATTRIBUTE_NAMES := {
&"": "未設定", &"appetite": "食欲", &"relaxation": "リラックス欲",
&"sexual": "性欲", &"gambling": "ギャンブル欲",
}
const STATE_NAMES := {&"BROKEN": "故障中", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力不足", &"NO_STAFF": "職員不足", &"CLOSED": "休業中", &"DEGRADED": "状態悪化", &"OPEN": "稼働中"}
const REASONS := {
&"FACILITY_NOT_FOUND": "施設が見つかりません。選び直してください。",
&"INVALID_POLICY": "営業設定を確認してください。",
&"INVALID_PRICE_MODE": "料金を選び直してください。",
&"INVALID_PAYLOAD": "営業設定を確認してください。",
&"FACILITY_CLOSED": "この施設は利用できません。",
&"BANKRUPT": "運営終了後は変更できません。",
}
var facility_option: OptionButton
var price_option: OptionButton
var business_open_button: CheckButton
var quote_label: Label
var effect_label: Label
var facility_status_label: Label
var today_label: Label
var explanation_label: Label
var apply_button: Button
var result_label: Label
var selected_facility_id: StringName = &""
var _runtime: Variant
var _style_button: Callable
var _report: Dictionary = {}
var _drafts: Dictionary = {}
var _pending_commands: Dictionary = {}
var _queue_instance_id := 0
var _sequence := 1
var _refreshing := false
var _built := false
func configure(runtime: Variant, style_button := Callable()) -> void:
_runtime = runtime
_style_button = style_button
func _ready() -> void:
name = "BusinessManagementPanel"
size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 6)
_build_interface()
_built = true
if _runtime != null:
if _runtime.has_signal(&"state_updated"):
_runtime.connect(&"state_updated", _on_snapshot_published)
elif _runtime.has_signal(&"snapshot_published"):
_runtime.connect(&"snapshot_published", _on_snapshot_published)
if _runtime.has_signal(&"command_completed"):
_runtime.connect(&"command_completed", _on_command_completed)
refresh()
func _build_interface() -> void:
add_child(_label("営業 · 料金と受付", "BusinessSummary"))
add_child(_label("設定する施設"))
facility_option = _option("BusinessFacilityOption")
facility_option.item_selected.connect(func(index: int) -> void:
selected_facility_id = StringName(facility_option.get_item_metadata(index))
refresh())
add_child(facility_option)
facility_status_label = _label("", "BusinessFacilityStatus")
add_child(facility_status_label)
add_child(_label("新規の料金"))
price_option = _option("BusinessPriceOption")
price_option.item_selected.connect(func(_index: int) -> void: _store_draft())
add_child(price_option)
quote_label = _label("", "BusinessPriceQuote")
quote_label.add_theme_font_size_override("font_size", 16)
add_child(quote_label)
effect_label = _label("", "BusinessPriceEffect")
add_child(effect_label)
business_open_button = CheckButton.new()
business_open_button.name = "BusinessOpenButton"
business_open_button.text = "新規の受付を開く"
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
business_open_button.add_theme_color_override(color_name, Color("55371e"))
business_open_button.add_theme_color_override("font_disabled_color", Color("766650"))
LayerSkin.register_toggle_text(business_open_button, LayerSkin.active_layer(self))
business_open_button.add_theme_font_size_override("font_size", 13)
business_open_button.toggled.connect(func(_enabled: bool) -> void: _store_draft())
add_child(business_open_button)
apply_button = _button("営業設定を適用", "ApplyBusinessPolicyButton", submit_policy)
add_child(apply_button)
explanation_label = _label(GUEST_POLICY_EXPLANATION, "BusinessPolicyExplanation")
add_child(explanation_label)
add_child(HSeparator.new())
today_label = _label("", "BusinessTodayResults")
add_child(today_label)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 4)
for entry: Array in [[&"staff", "従業員"], [&"product", "商品"], [&"ledger", "帳簿"]]:
var button := _button(entry[1], "BusinessAction_%s" % entry[0], func() -> void: action_requested.emit(entry[0]))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
actions.add_child(button)
add_child(actions)
result_label = _label("", "BusinessResult")
add_child(result_label)
func refresh() -> void:
if not _built:
return
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
if queue != null and queue.get_instance_id() != _queue_instance_id:
_queue_instance_id = queue.get_instance_id()
_drafts.clear()
facility_option.get_popup().hide()
price_option.get_popup().hide()
_synchronize_commands(queue)
_report = {}
if _runtime != null and _runtime.has_method(&"get_business_management_report"):
var value: Variant = _runtime.call(&"get_business_management_report")
if value is Dictionary:
_report = value
_refreshing = true
_fill_facilities()
var row := _facility_row(selected_facility_id)
var draft := _selected_draft(row)
_fill_modes(StringName(draft.get(&"price_mode", &"standard")))
business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true)))
var pending := _has_pending(selected_facility_id)
var can_price := bool(row.get(&"can_price", false))
var is_guest_service := StringName(row.get(&"category", &"")) in [&"guest_service", &"restricted_service"]
var is_cleaning := StringName(row.get(&"definition_id", &"")) == &"cleaning_room"
var is_supply := StringName(row.get(&"definition_id", &"")) in [&"kitchen", &"warehouse"]
business_open_button.text = "営業する" if is_supply else "新規の受付を開く"
explanation_label.text = SUPPLY_POLICY_EXPLANATION if is_supply else GUEST_POLICY_EXPLANATION
if is_cleaning:
business_open_button.text = "清掃業務を有効にする"
explanation_label.text = "配置した清掃員が汚れた施設へ向かいます。清掃業務を無効にすると、途中の清掃も中断します。維持費と給与は継続します。"
facility_option.disabled = _rows(&"facilities").is_empty()
price_option.disabled = row.is_empty() or not can_price or pending or price_option.item_count == 0
business_open_button.disabled = row.is_empty() or pending
apply_button.disabled = row.is_empty() or pending or not _is_changed(row, draft)
apply_button.text = "設定を受理済み…" if pending else "営業設定を適用"
if row.is_empty():
facility_status_label.text = "施設が完成すると営業を設定できます。"
quote_label.text = "施設を選んでください。"
effect_label.text = ""
if int(row.get(&"faith_per_service", 0)) > 0:
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
today_label.text = "本日の実績はまだありません。"
else:
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"]
if is_supply:
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
if is_cleaning:
facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
if int(row.get(&"cleanliness", -1)) >= 0:
facility_status_label.text += "\n清潔度 %d / 100" % int(row[&"cleanliness"])
if not String(row.get(&"cleaning_status", "")).is_empty():
facility_status_label.text += "\n" + String(row[&"cleaning_status"])
if can_price:
var mode := StringName(draft.get(&"price_mode", &"standard"))
var quoted := FacilityPricing.apply_multiplier(FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode}) * int(row.get(&"donation_multiplier", 1)), int(row.get(&"floor_price_multiplier_bp", 10000)))
var unit := String({&"standard_room": "泊", &"premium_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回"))
quote_label.text = "通常料金 · 設定後 %dG / %s\n現在 %dG · 標準 %dG" % [quoted, unit, int(row.get(&"price", 0)), int(row.get(&"base_price", 0))]
effect_label.text = _price_effect(mode)
if int(row.get(&"donation_multiplier", 1)) == 3:
effect_label.text += "\nアメリア配置:寄付金3倍"
if int(row.get(&"floor_price_multiplier_bp", 10000)) == 12000:
effect_label.text += "\nアンリ配置フロア:利用料金+20%"
else:
quote_label.text = "通常の利用料金はありません(現在 %dG)。" % int(row.get(&"price", 0)) if is_guest_service else "この施設に利用料金はありません。"
effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。"
if is_cleaning: effect_label.text = "清掃業務の有効・無効を設定できます。"
if can_price or is_guest_service:
var service_pricing: Dictionary = row.get(&"service_pricing", {})
var attribute: Variant = service_pricing.get(&"service_attribute", null)
quote_label.text += "\nサービス属性:%s\n欲求一致加算:未設定" % String(SERVICE_ATTRIBUTE_NAMES.get(attribute, "未設定"))
if int(row.get(&"faith_per_service", 0)) > 0:
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
today_label.text = "本日の記帳 · 00:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌日の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))]
_refreshing = false
func select_facility(id: StringName) -> void:
selected_facility_id = id
refresh()
func get_report() -> Dictionary:
return _report.duplicate(true)
func submit_policy() -> void:
var row := _facility_row(selected_facility_id)
if row.is_empty() or _has_pending(selected_facility_id):
return
var draft := _selected_draft(row)
if not _is_changed(row, draft):
return
if _runtime == null or not _runtime.has_method(&"submit_command"):
_set_result("営業サービスへ接続できません。", false)
return
var patch: Dictionary = {&"business_open": bool(draft.get(&"business_open", true))}
if bool(row.get(&"can_price", false)):
patch[&"price_mode"] = StringName(draft.get(&"price_mode", &"standard"))
var payload := {&"facility_id": selected_facility_id, &"patch": patch}
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
_sequence += 1
var result: Variant = _runtime.call(&"submit_command", COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
if result == null or not result.has_method(&"is_success"):
_set_result("操作に応答がありません。", false)
return
var queued := false
var queue: Variant = _runtime.get(&"command_queue")
if queue != null:
for command: Dictionary in queue.create_snapshot().get("pending", []):
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
if result.is_success() and queued:
_pending_commands[int(result.command_id)] = payload.duplicate(true)
_set_result("営業設定を受理しました。次の時間更新で反映します。", true)
else:
_show_result(result, payload)
refresh()
func _show_result(result: Variant, payload: Dictionary) -> void:
if not result.is_success():
var details: Dictionary = result.details if result.details is Dictionary else {}
var reason := StringName(details.get(&"reason", &"UNAVAILABLE"))
_set_result(String(REASONS.get(reason, "営業設定を反映できませんでした(%s)。" % reason)), false)
return
var id := StringName(payload.get(&"facility_id", &""))
_drafts.erase(id)
var row := _facility_row(id)
_set_result("%sの営業設定を反映しました。" % String(row.get(&"display_name", "施設")), true)
func _selected_draft(row: Dictionary) -> Dictionary:
for payload: Dictionary in _pending_commands.values():
if StringName(payload.get(&"facility_id", &"")) == selected_facility_id:
var draft := row.duplicate(true)
draft.merge(payload.get(&"patch", {}), true)
return draft
return _drafts.get(selected_facility_id, row)
func _is_changed(row: Dictionary, draft: Dictionary) -> bool:
return bool(row.get(&"business_open", true)) != bool(draft.get(&"business_open", true)) or (bool(row.get(&"can_price", false)) and StringName(row.get(&"price_mode", &"standard")) != StringName(draft.get(&"price_mode", &"standard")))
func _store_draft() -> void:
if _refreshing or selected_facility_id.is_empty() or _has_pending(selected_facility_id):
return
var mode := &"standard"
if price_option.selected >= 0:
mode = StringName(price_option.get_item_metadata(price_option.selected))
_drafts[selected_facility_id] = {&"business_open": business_open_button.button_pressed, &"price_mode": mode}
refresh()
func _synchronize_commands(queue: Variant) -> void:
_pending_commands.clear()
for metadata: Dictionary in queue.get_accepted_command_metadata():
var key := String(metadata.get("idempotency_key", ""))
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
for command: Dictionary in queue.create_snapshot().get("pending", []):
if StringName(command.get("command_type", &"")) == COMMAND:
_pending_commands[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
func _has_pending(id: StringName) -> bool:
for payload: Dictionary in _pending_commands.values():
if StringName(payload.get(&"facility_id", &"")) == id:
return true
return false
func _on_command_completed(result: Variant) -> void:
if not _pending_commands.has(int(result.command_id)):
return
var payload: Dictionary = _pending_commands[int(result.command_id)]
_pending_commands.erase(int(result.command_id))
_show_result(result, payload)
refresh()
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
if is_visible_in_tree():
refresh()
func _fill_facilities() -> void:
if facility_option.get_popup().visible:
return
facility_option.clear()
var rows := _rows(&"facilities")
if rows.is_empty():
facility_option.add_item("完成した施設がありません")
facility_option.set_item_metadata(0, &"")
selected_facility_id = &""
return
var selected_index := 0
for row: Dictionary in rows:
facility_option.add_item(_facility_name(row))
var index := facility_option.item_count - 1
var id := StringName(row.get(&"facility_id", &""))
facility_option.set_item_metadata(index, id)
if id == selected_facility_id:
selected_index = index
facility_option.select(selected_index)
selected_facility_id = StringName(facility_option.get_item_metadata(selected_index))
func _fill_modes(selected: StringName) -> void:
if price_option.get_popup().visible:
return
price_option.clear()
var selected_index := 0
for mode: Dictionary in _rows(&"modes"):
price_option.add_item("%s(%d%%)" % [String(mode.get(&"label", "料金")), int(mode.get(&"percent", 100))])
var index := price_option.item_count - 1
price_option.set_item_metadata(index, StringName(mode.get(&"id", &"standard")))
if StringName(mode.get(&"id", &"standard")) == selected:
selected_index = index
if price_option.item_count > 0:
price_option.select(selected_index)
func _facility_name(row: Dictionary) -> String:
var label := String(row.get(&"display_name", row.get(&"facility_id", "施設")))
var origin: Variant = row.get(&"origin", null)
var layer := "裏" if int(row.get(&"layer", 0)) == 1 else "表"
if origin is Vector2i or origin is Vector3i:
label += " (%d,%d・%s)" % [origin.x, origin.y, layer]
elif origin is Dictionary:
label += " (%d,%d・%s)" % [int(origin.get(&"x", 0)), int(origin.get(&"y", 0)), layer]
return label
func _price_effect(mode: StringName) -> String:
match mode:
&"charitable": return "お財布にやさしい料金。満足度が大きく上がる一方、1回の売上は少なくなります。"
&"affordable": return "手頃な料金。満足度が上がり、1回の売上は少し少なくなります。"
&"expensive": return "1回の売上が増えますが、満足度は下がります。お客様の懐具合にもご注意を。"
&"exorbitant": return "とても高い料金。満足度が大きく下がり、お客様が支払えない場合もあります。"
_: return "基準となる料金。価格による満足度の増減はありません。"
func _rows(key: StringName) -> Array[Dictionary]:
var rows: Array[Dictionary] = []
var value: Variant = _report.get(key, [])
if value is Array:
for row: Variant in value:
if row is Dictionary:
rows.append(row)
return rows
func _facility_row(id: StringName) -> Dictionary:
for row: Dictionary in _rows(&"facilities"):
if StringName(row.get(&"facility_id", &"")) == id:
return row
return {}
func _set_result(message: String, success: bool) -> void:
result_label.text = message
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
status_requested.emit(message, success)
func _label(value: String, node_name := "") -> Label:
var label := Label.new()
if not node_name.is_empty():
label.name = node_name
label.text = value
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(label, LayerSkin.active_layer(self))
return label
func _button(value: String, node_name: String, callback: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = value
button.custom_minimum_size.y = 30
button.add_theme_font_size_override("font_size", 13)
button.pressed.connect(callback)
if _style_button.is_valid():
_style_button.call(button)
return button
func _option(node_name: String) -> OptionButton:
var picker := OptionButton.new()
picker.name = node_name
picker.fit_to_longest_item = false
picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
picker.custom_minimum_size.y = 30
if _style_button.is_valid():
_style_button.call(picker)
return picker