Add business pricing and closure controls with compatible digest optimization

This commit is contained in:
Takeshi
2026-09-09 12:04:12 +09:00
parent 071e338e82
commit b3b82ef03e
35 changed files with 2203 additions and 59 deletions
+76 -24
View File
@@ -11,6 +11,19 @@ extends RefCounted
const FORMAT_VERSION := 1
const MAX_NESTING_DEPTH := 128
# A single digest owns these caches; nothing survives a call or retains snapshot
# values. Only private arrays of immutable, homogeneous keys become cache keys.
# Separate buckets preserve the distinct String / StringName canonical tags.
const MAX_CACHED_LAYOUTS := 256
const MAX_CACHED_LAYOUT_KEYS := 32
const MAX_CACHED_KEY_LENGTH := 128
class KeyLayouts:
extends RefCounted
var strings: Dictionary = {}
var names: Dictionary = {}
static func compute(value: Variant) -> String:
var canonical := canonical_bytes(value)
@@ -27,12 +40,12 @@ static func compute(value: Variant) -> String:
static func canonical_bytes(value: Variant) -> PackedByteArray:
var output := PackedByteArray()
_append_ascii(output, "the-tree:simulation-digest:v%d;" % FORMAT_VERSION)
if not _encode_value(value, output, 0):
if not _encode_value(value, output, 0, KeyLayouts.new()):
return PackedByteArray()
return output
static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -> bool:
static func _encode_value(value: Variant, output: PackedByteArray, depth: int, layouts: KeyLayouts) -> bool:
if depth > MAX_NESTING_DEPTH:
push_error("SimulationDigest data exceeds the maximum nesting depth")
return false
@@ -107,9 +120,9 @@ static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -
and _append_float64(output, color.a)
)
TYPE_ARRAY:
return _encode_array(value, output, depth)
return _encode_array(value, output, depth, layouts)
TYPE_DICTIONARY:
return _encode_dictionary(value, output, depth)
return _encode_dictionary(value, output, depth, layouts)
TYPE_PACKED_BYTE_ARRAY:
_append_sized_bytes(output, "pb", value)
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
@@ -130,34 +143,73 @@ static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -
return true
static func _encode_array(value: Array, output: PackedByteArray, depth: int) -> bool:
static func _encode_array(value: Array, output: PackedByteArray, depth: int, layouts: KeyLayouts) -> bool:
_append_ascii(output, "a%d:" % value.size())
# Event IDs and several saved histories are typed string arrays. Their element
# type is guaranteed by Godot, so avoid the recursive Variant dispatch for each
# entry while keeping the same depth limit and exact bytes.
if value.is_typed() and value.get_typed_builtin() in [TYPE_STRING, TYPE_STRING_NAME]:
if not value.is_empty() and depth >= MAX_NESTING_DEPTH:
push_error("SimulationDigest data exceeds the maximum nesting depth")
return false
var tag := "s" if value.get_typed_builtin() == TYPE_STRING else "q"
for item in value:
_append_sized_bytes(output, tag, String(item).to_utf8_buffer())
return true
for item in value:
if not _encode_value(item, output, depth + 1):
if not _encode_value(item, output, depth + 1, layouts):
return false
return true
static func _encode_dictionary(value: Dictionary, output: PackedByteArray, depth: int) -> bool:
var entries: Array[Dictionary] = []
for key in value:
var encoded_key := PackedByteArray()
if not _encode_value(key, encoded_key, depth + 1):
return false
entries.append({
"sort_key": encoded_key.hex_encode(),
"encoded_key": encoded_key,
"value": value[key],
})
entries.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
return String(left["sort_key"]) < String(right["sort_key"])
)
static func _encode_dictionary(value: Dictionary, output: PackedByteArray, depth: int, layouts: KeyLayouts) -> bool:
if not value.is_empty() and depth >= MAX_NESTING_DEPTH:
push_error("SimulationDigest data exceeds the maximum nesting depth")
return false
var keys := value.keys()
var cached_layouts := _layout_bucket(keys, layouts)
var entries: Array = []
if not cached_layouts.is_empty() and cached_layouts.has(keys):
entries = cached_layouts[keys]
else:
for key in keys:
var encoded_key := PackedByteArray()
if not _encode_value(key, encoded_key, depth + 1, layouts):
return false
entries.append([key, encoded_key, encoded_key.hex_encode()])
entries.sort_custom(func(left: Array, right: Array) -> bool:
return String(left[2]) < String(right[2])
)
if _can_cache_keys(keys) and cached_layouts.size() < MAX_CACHED_LAYOUTS:
# keys is a fresh private array from Dictionary.keys(). It and entries
# are never mutated once stored. Native Dictionary lookup compares full
# array contents after hashing, so hash collisions cannot alias layouts.
cached_layouts[keys] = entries
_append_ascii(output, "d%d:" % entries.size())
for entry in entries:
var encoded_key: PackedByteArray = entry["encoded_key"]
output.append_array(encoded_key)
if not _encode_value(entry["value"], output, depth + 1):
for entry: Array in entries:
output.append_array(entry[1])
# Read each value live. A reused key layout never caches child containers
# or their bytes, including when several dictionaries share the schema.
if not _encode_value(value[entry[0]], output, depth + 1, layouts):
return false
return true
static func _layout_bucket(keys: Array, layouts: KeyLayouts) -> Dictionary:
if _can_cache_keys(keys):
return layouts.strings if typeof(keys[0]) == TYPE_STRING else layouts.names
return {}
static func _can_cache_keys(keys: Array) -> bool:
if keys.is_empty() or keys.size() > MAX_CACHED_LAYOUT_KEYS:
return false
var key_type := typeof(keys[0])
if key_type != TYPE_STRING and key_type != TYPE_STRING_NAME:
return false
for key in keys:
if typeof(key) != key_type or String(key).length() > MAX_CACHED_KEY_LENGTH:
return false
return true
+16 -1
View File
@@ -2,6 +2,7 @@ class_name FacilityInstance
extends RefCounted
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
const FacilityPricingType = preload("res://scripts/facility/facility_pricing.gd")
## Values are deliberately ordered by operational priority.
enum State {
@@ -81,6 +82,8 @@ func configure(
access_available = bool(_value(options, &"access_available", true))
power_available = bool(_value(options, &"power_available", true))
operator_open = bool(_value(options, &"operator_open", true))
if _has_value(policy_current, &"business_open"):
operator_open = bool(_value(policy_current, &"business_open", operator_open))
var intervals := _parse_intervals(_value(options, &"opening_intervals", []))
if not intervals.is_empty():
opening_intervals = intervals
@@ -98,7 +101,8 @@ func is_valid() -> bool:
and base_service_minutes > 0 \
and staff_min >= 0 \
and staff_max >= staff_min \
and _intervals_are_valid(opening_intervals)
and _intervals_are_valid(opening_intervals) \
and bool(FacilityPricingType.validate_patch(policy_current)[&"valid"])
func set_opening_hours(open_minute_of_day: int, close_minute_of_day: int) -> bool:
@@ -144,6 +148,8 @@ func set_operational_conditions(conditions: Dictionary, absolute_minute: int) ->
power_available = bool(_value(conditions, &"power_available", power_available))
if _has_value(conditions, &"operator_open"):
operator_open = bool(_value(conditions, &"operator_open", operator_open))
if _has_value(policy_current, &"business_open"):
apply_policy_patch({&"business_open": operator_open})
if _has_value(conditions, &"active_staff_ids"):
active_staff_ids = _sorted_unique_names(_value(conditions, &"active_staff_ids", []))
if _has_value(conditions, &"degraded_reasons"):
@@ -218,6 +224,8 @@ func apply_policy_patch(patch: Dictionary) -> bool:
policy_current.erase(String(key))
policy_current[key] = _copy(value)
changed = true
if _has_value(patch, &"business_open"):
operator_open = bool(_value(patch, &"business_open", operator_open))
if changed:
policy_revision += 1
return changed
@@ -360,6 +368,13 @@ static func _snapshot_shapes_are_valid(snapshot: Dictionary) -> bool:
for field: StringName in dictionary_fields:
if not snapshot.get(field, null) is Dictionary:
return false
for field: StringName in [&"policy_current", &"policy_pending"]:
if not bool(FacilityPricingType.validate_patch(snapshot[field])[&"valid"]):
return false
var saved_policy: Dictionary = snapshot[&"policy_current"]
if _has_value(saved_policy, &"business_open") \
and _value(saved_policy, &"business_open", true) != snapshot.get(&"operator_open", true):
return false
return true
+87
View File
@@ -0,0 +1,87 @@
class_name FacilityPricing
extends RefCounted
## Shared integer pricing rules for quotations, guest outcomes and management UI.
## Unrelated policy keys remain the responsibility of their owning systems.
const DEFAULT_MODE := &"standard"
const MODES: Array[Dictionary] = [
{&"id": &"charitable", &"label": "奉仕価格", &"percent": 60, &"satisfaction_delta": 10},
{&"id": &"affordable", &"label": "安価", &"percent": 80, &"satisfaction_delta": 5},
{&"id": &"standard", &"label": "標準", &"percent": 100, &"satisfaction_delta": 0},
{&"id": &"expensive", &"label": "高価", &"percent": 150, &"satisfaction_delta": -5},
{&"id": &"exorbitant", &"label": "ぼったくり", &"percent": 250, &"satisfaction_delta": -20},
]
static func modes() -> Array[Dictionary]:
return MODES.duplicate(true)
static func quote(base_price: int, policy: Dictionary) -> int:
var percent := int(_mode(policy)[&"percent"])
# Currency is integer gold. An exact half gold rounds up, without depending
# on floating-point rounding (35 G at 150 percent is 53 G).
@warning_ignore("integer_division")
var result := (maxi(base_price, 0) * percent + 50) / 100
return result
static func satisfaction_modifier(
policy: Dictionary,
quality: int,
expected_quality: int
) -> int:
var modifier := int(_mode(policy)[&"satisfaction_delta"])
# GAME_SPECIFICATION 7.4 includes a difference of exactly twenty points.
if modifier < 0 and quality - expected_quality >= 20:
# Keep the same nearest-integer rule for a half penalty: -2.5 becomes -3.
@warning_ignore("integer_division")
return -((-modifier + 1) / 2)
return modifier
static func validate_patch(patch: Dictionary) -> Dictionary:
if _has(patch, &"price_mode"):
var mode: Variant = _value(patch, &"price_mode", DEFAULT_MODE)
if not (mode is String or mode is StringName) or not _known_mode(StringName(mode)):
return _invalid(&"INVALID_PRICE_MODE", "価格設定は5段階のいずれかを選んでください。")
if _has(patch, &"business_open") and not _value(patch, &"business_open", true) is bool:
return _invalid(&"INVALID_BUSINESS_OPEN", "営業状態は営業中または休業を指定してください。")
var normalized: Dictionary = {}
for raw_key: Variant in patch:
if not (raw_key is String or raw_key is StringName):
return _invalid(&"INVALID_POLICY_KEY", "施設方針の項目名が正しくありません。")
var value: Variant = patch[raw_key]
normalized[StringName(raw_key)] = value.duplicate(true) if value is Array or value is Dictionary else value
if normalized.has(&"price_mode"):
normalized[&"price_mode"] = StringName(normalized[&"price_mode"])
return {&"valid": true, &"reason": &"", &"message": "", &"patch": normalized}
static func _mode(policy: Dictionary) -> Dictionary:
var value: Variant = _value(policy, &"price_mode", DEFAULT_MODE)
if value is String or value is StringName:
for mode: Dictionary in MODES:
if mode[&"id"] == StringName(value):
return mode
return MODES[2]
static func _known_mode(mode_id: StringName) -> bool:
for mode: Dictionary in MODES:
if mode[&"id"] == mode_id:
return true
return false
static func _invalid(reason: StringName, message: String) -> Dictionary:
return {&"valid": false, &"reason": reason, &"message": message, &"patch": {}}
static func _has(source: Dictionary, key: StringName) -> bool:
return source.has(key) or source.has(String(key))
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
return source.get(key, source.get(String(key), fallback))
+1
View File
@@ -0,0 +1 @@
uid://d4hh37purloll
+28 -3
View File
@@ -15,6 +15,7 @@ const FacilityInstanceType = preload("res://scripts/facility/facility_instance.g
const ServiceRequestType = preload("res://scripts/facility/service_request.gd")
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
const CommandResultType = preload("res://scripts/core/command_result.gd")
const FacilityPricingType = preload("res://scripts/facility/facility_pricing.gd")
const SNAPSHOT_VERSION := 1
const STAFF_REPLACEMENT_GRACE_MINUTES := 5
@@ -175,15 +176,22 @@ func set_policy(
return _not_found("Facility instance", instance_id)
if patch.is_empty():
return _invalid("Policy patch cannot be empty.")
var validation := FacilityPricingType.validate_patch(patch)
if not bool(validation[&"valid"]):
return _invalid(String(validation[&"message"]), {&"reason": validation[&"reason"]})
var normalized_patch: Dictionary = validation[&"patch"]
if effective_at >= 0 and effective_at > current_minute:
instance.schedule_policy_patch(patch, effective_at)
instance.schedule_policy_patch(normalized_patch, effective_at)
last_error = ""
return _success({
&"instance_id": instance_id,
&"pending_revision": instance.policy_pending_revision,
&"effective_at": effective_at,
})
var changed := instance.apply_policy_patch(patch)
var changed := instance.apply_policy_patch(normalized_patch)
var old_state := instance.state
instance.evaluate_state(current_minute)
_emit_state_change(instance, old_state)
last_error = ""
return _success({
&"instance_id": instance_id,
@@ -205,7 +213,7 @@ func create_request(
return null
var quotation := options.duplicate(true)
if not _has_value(quotation, &"quoted_price"):
quotation[&"quoted_price"] = instance.base_price
quotation[&"quoted_price"] = FacilityPricingType.quote(instance.base_price, instance.policy_current)
if not _has_value(quotation, &"expected_quality"):
quotation[&"expected_quality"] = instance.base_quality
if not _has_value(quotation, &"service_minutes"):
@@ -560,6 +568,11 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
if instance == null or restored_instances.has(instance.instance_id):
last_error = "Facility snapshot contains an invalid or duplicate instance."
return false
if not _saved_policy_is_valid(instance.policy_current) or not _saved_policy_is_valid(instance.policy_pending):
return false
for active: ServiceInstance in instance.active_services.values():
if not _saved_policy_is_valid(active.policy_snapshot):
return false
restored_instances[instance.instance_id] = instance
var restored_requests: Dictionary = {}
@@ -571,6 +584,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
if request == null or restored_requests.has(request.request_id):
last_error = "Facility snapshot contains an invalid or duplicate request."
return false
if not _saved_policy_is_valid(request.policy_snapshot):
return false
restored_requests[request.request_id] = request
var restored_history: Dictionary = {}
@@ -582,6 +597,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
if service == null or not service.is_terminal() or restored_history.has(service.service_id):
last_error = "Facility snapshot contains invalid service history."
return false
if not _saved_policy_is_valid(service.policy_snapshot):
return false
restored_history[service.service_id] = service
var restored_known_instances: Variant = _id_set(_value(snapshot, &"known_instance_ids", []))
@@ -619,6 +636,14 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
return true
func _saved_policy_is_valid(policy: Dictionary) -> bool:
var validation := FacilityPricingType.validate_patch(policy)
if not bool(validation[&"valid"]):
last_error = "Facility snapshot contains an invalid policy: %s" % validation[&"reason"]
return false
return true
func _validate_restored_graph(
instances: Dictionary,
requests: Dictionary,
+35
View File
@@ -666,6 +666,41 @@ func enter_queue(
return {&"ok": true, &"patience_remaining": guest.patience_remaining}
## Closure asks the guest to choose again, without charging a patience penalty.
## The callback releases the owning facility request and inventory reservations.
func release_queue_for_replanning(
guest_id: StringName,
absolute_minute: int,
release_callback: Callable = Callable()
) -> Dictionary:
if _external_mutation_blocked:
return _error_result("Guest mutation is blocked while committed notifications are delivered.")
var guest := get_guest(guest_id)
if guest == null or guest.state != GuestRuntimeType.State.QUEUING:
return _error_result("Guest is not in a queue.")
if not _transition_is_available(guest, GuestRuntimeType.State.DECIDING, absolute_minute):
return _error_result("Guest cannot atomically replan the queue in this tick.")
var facility_id := guest.target_facility_id
var request_id := guest.queue_request_id
if facility_id.is_empty() or request_id.is_empty():
return _error_result("Queued guest is missing a facility or request.")
if release_callback.is_valid():
var external_result: Variant = release_callback.call(guest_id, facility_id, request_id)
if not _callback_succeeded(external_result):
return _error_result("Facility queue removal failed; guest state was not changed.")
guest.target_facility_id = &""
guest.queue_request_id = &""
guest.path_handle = null
_apply_transition(guest, GuestRuntimeType.State.DECIDING, absolute_minute, &"FACILITY_CLOSED")
last_error = ""
return {
&"ok": true,
&"facility_id": facility_id,
&"request_id": request_id,
&"patience_remaining": guest.patience_remaining,
}
func tick_queue_patience(
guest_id: StringName,
absolute_minute: int,
@@ -216,6 +216,8 @@ func _pickup(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, mi
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
if source == null or not _cell_touches_facility(staff.current_cell, source):
return _block_before_pickup(request, "INVALID_PICKUP_ACCESS", minute) and false
if not source.operator_open:
return _block_before_pickup(request, "SOURCE_CLOSED", minute) and false
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
if destination == null or not destination.operator_open or not _has_storage_space(destination, request.total_weight):
return _block_before_pickup(request, "DESTINATION_FULL_OR_CLOSED", minute) and false
+383
View File
@@ -0,0 +1,383 @@
class_name BusinessManagementPanel
extends VBoxContainer
## 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 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(&"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"))
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_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
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 = ""
today_label.text = "本日の実績はまだありません。"
else:
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人" % [("営業する設定" 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_count", 0)), int(row.get(&"active_count", 0))]
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 can_price:
var mode := StringName(draft.get(&"price_mode", &"standard"))
var quoted := FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode})
var unit := String({&"standard_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)
else:
quote_label.text = "この施設に利用料金はありません。"
effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。"
today_label.text = "本日の記帳 · 06: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
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
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"))
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
@@ -0,0 +1 @@
uid://byrn3mfhqhpqj
+1 -1
View File
@@ -104,7 +104,7 @@ func refresh() -> void:
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 "差額あり"]
_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 lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"]
var rows: Dictionary = summary.get(&"facility_profit_loss", {})
var ids := rows.keys()
+1
View File
@@ -41,6 +41,7 @@ const REASONS := {
&"CARRIER_TASK_MISSING": "運搬作業との接続を確認してください。",
&"CARRIER_UNAVAILABLE": "担当の運搬員が現在作業できません。",
&"SOURCE_STOCK_EXPIRED": "荷元の在庫が消費期限を過ぎました。",
&"SOURCE_CLOSED": "荷元の施設が休業しています。",
&"WAITING_FOR_SOURCE_STOCK": "荷元への入荷を待っています。",
&"SOURCE_WEIGHT_CHANGED": "荷物の重量が変わったため再手配が必要です。",
&"PICKUP_FAILED": "積込できませんでした。再手配を待っています。",