Add business pricing and closure controls with compatible digest optimization
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4hh37purloll
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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": "積込できませんでした。再手配を待っています。",
|
||||
|
||||
Reference in New Issue
Block a user