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
+16
View File
@@ -14,6 +14,7 @@ test:
$(MAKE) test-s2
$(MAKE) test-s3
$(MAKE) test-supply
$(MAKE) test-business
$(MAKE) test-s4
$(MAKE) test-s5
$(MAKE) test-s6
@@ -91,9 +92,11 @@ test-ui:
$(GODOT) --headless --path . --script res://tests/ui/test_main_dashboard.gd
$(GODOT) --headless --path . --script res://tests/ui/test_staff_management.gd
$(GODOT) --headless --path . --script res://tests/ui/test_supply_management.gd
$(GODOT) --headless --path . --script res://tests/ui/test_business_management.gd
$(MAKE) test-opening
test-perf:
$(GODOT) --headless --path . --script res://tests/performance/test_digest_performance.gd
$(GODOT) --headless --path . --script res://tests/performance/test_perf_mvp_001.gd
test-perf-full:
@@ -139,3 +142,16 @@ capture-supply:
preview-supply:
THE_TREE_SUPPLY_PREVIEW=1 $(GODOT) --path . --script res://tests/supply/test_normal_supply_chain.gd
.PHONY: test-business
test-business:
$(GODOT) --headless --path . --script res://tests/s2/test_facility_pricing.gd
$(GODOT) --headless --path . --script res://tests/business/test_business_operations.gd
$(GODOT) --headless --path . --script res://tests/business/test_business_closure.gd
.PHONY: preview-business capture-business
preview-business:
THE_TREE_BUSINESS_PREVIEW=1 THE_TREE_SUPPLY_PREVIEW=1 $(GODOT) --path . --script res://tests/supply/test_normal_supply_chain.gd
capture-business:
THE_TREE_BUSINESS_UI_CAPTURE=1 $(GODOT) --path . --script res://tests/ui/test_business_management.gd
+138 -3
View File
@@ -6590,9 +6590,13 @@ func _execute_create_service_request(command: SimulationCommand) -> CommandResul
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Service request options must be a Dictionary.")
var options: Dictionary = raw_options.duplicate(true)
var quoted_price := maxi(
int(options.get(&"quoted_price", options.get("quoted_price", instance.base_price))),
int(options.get(&"quoted_price", options.get("quoted_price", FacilityPricing.quote(_business_base_price(instance), instance.policy_current)))),
0
)
# Freeze the same price used by candidate selection and the business panel.
options[&"quoted_price"] = quoted_price
if not instance.operator_open or not instance.is_within_opening_hours(_runtime_event_minute()):
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "This facility is closed to new service requests.", {&"reason": &"FACILITY_CLOSED"})
var payer_account := _guest_wallet_account(guest_ids[0])
var committed_price := _outstanding_service_commitment(guest_ids[0])
if not economy_service.account_allows_negative(payer_account) \
@@ -7597,6 +7601,7 @@ func _advance_guest_vitals(absolute_minute: int) -> void:
func _advance_guest_queues(absolute_minute: int) -> void:
if _operations_tick_blocked:
return
_release_closed_business_queues(absolute_minute)
var guests_by_request: Dictionary = {}
for guest: GuestRuntime in guest_service.get_guests():
if guest.state != GuestRuntime.State.QUEUING:
@@ -7606,6 +7611,9 @@ func _advance_guest_queues(absolute_minute: int) -> void:
(guests_by_request[guest.queue_request_id] as Array).append(guest)
for request_id: StringName in _sorted_name_keys(guests_by_request):
var grouped_guests: Array = guests_by_request[request_id]
var queued_facility := facility_service.get_instance(grouped_guests[0].target_facility_id)
if queued_facility != null and (not queued_facility.operator_open or not queued_facility.is_within_opening_hours(absolute_minute)):
continue # A deferred closure must not spend patience while retrying.
var abandon_group := false
for guest: GuestRuntime in grouped_guests:
if guest.patience_remaining <= 1:
@@ -8239,6 +8247,8 @@ func _project_guest_candidates(guest_id: StringName) -> Array:
var definition := definition_registry.get_facility(facility.definition_id)
if definition == null:
continue
if &"entrance" in definition.tags and _guest_has_checked_in(guest):
continue
var has_inputs := true
for item_id: StringName in _sorted_name_keys(definition.inventory_inputs):
if inventory_service.available_quantity(facility.local_inventory_id, item_id) < int(definition.inventory_inputs[item_id]):
@@ -8256,7 +8266,7 @@ func _project_guest_candidates(guest_id: StringName) -> Array:
&"reachable": bool(access.get(&"reachable", false)),
&"travel_minutes": maxi(path.size() - 1, 0),
&"expected_wait": facility.queue_entries.size() * facility.base_service_minutes / maxi(facility.capacity, 1),
&"estimated_total_gold": facility.base_price,
&"estimated_total_gold": FacilityPricing.quote(_business_base_price(facility), facility.policy_current),
&"expected_quality": clampi(facility.base_quality, 0, 100),
&"need_relief": need_relief,
&"risk_level": 0,
@@ -10638,7 +10648,7 @@ func _on_service_completed(service: ServiceInstance) -> void:
guest_id,
service.service_id,
effective_minute,
clampi(service.quality_snapshot, 0, 100),
_service_satisfaction_sample(service, guest),
_service_need_effects(service.facility_id)
)
if not bool(synchronized.get(&"ok", false)):
@@ -11177,3 +11187,128 @@ func get_supply_management_report() -> Dictionary:
func preview_supply_purchase(warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary:
return supply_management.preview_purchase(self, warehouse_id, item_id, quantity)
# Existing saves retain each facility's stored base rate; reception never quotes
# a new direct sale. Already queued/in-service requests retain their old quote.
func _business_base_price(facility: FacilityInstance) -> int:
return 0 if facility.definition_id == &"reception" else facility.base_price
func get_business_management_report() -> Dictionary:
var minute: int = _clock.absolute_minute
var start := maxi(0, int(floor(float(minute - 360) / 1440.0)) * 1440 + 360)
var totals: Dictionary = {}
for entry: LedgerEntry in economy_service.get_entries():
if entry.absolute_minute < start or entry.absolute_minute > minute:
continue
if not totals.has(entry.source_entity_id):
totals[entry.source_entity_id] = {&"revenue": 0, &"expenses": 0}
if entry.category == &"SERVICE_SALE":
totals[entry.source_entity_id][&"revenue"] += entry.amount
elif entry.category in [&"OPERATING_EXPENSE", &"STAFF_SIGNING_COST", &"EVENT_ACTION", &"EVENT_COST_COMMITTED", &"DEFENSE_SKILL_COST", &"AUTHORITY_FINE", &"REFUND"]:
totals[entry.source_entity_id][&"expenses"] += entry.amount
var rows: Array[Dictionary] = []
for facility: FacilityInstance in facility_service.get_instances():
var definition := definition_registry.get_facility(facility.definition_id)
if definition == null:
continue
var amounts: Dictionary = totals.get(facility.instance_id, {})
var base := _business_base_price(facility)
rows.append({
&"facility_id": facility.instance_id,
&"definition_id": facility.definition_id,
&"display_name": definition.display_name,
&"origin": Vector2i(facility.origin.x, facility.origin.y),
&"layer": facility.origin.z,
&"state": StringName(FacilityInstance.State.keys()[facility.state]),
&"business_open": facility.operator_open,
&"price_mode": StringName(facility.policy_current.get(&"price_mode", &"standard")),
&"base_price": base,
&"price": FacilityPricing.quote(base, facility.policy_current),
&"can_price": base > 0,
&"queued_count": facility.queue_entries.size(),
&"active_count": facility.active_services.size(),
&"today_revenue": int(amounts.get(&"revenue", 0)),
&"today_expenses": int(amounts.get(&"expenses", 0)),
})
return {&"minute": minute, &"modes": FacilityPricing.modes(), &"facilities": rows}
func _guest_has_checked_in(guest: GuestRuntime) -> bool:
for visited_id: StringName in guest.visited_facility_ids:
var visited := facility_service.get_instance(visited_id)
if visited != null and visited.definition_id == &"reception":
return true
return false
func _service_satisfaction_sample(service: ServiceInstance, _guest: GuestRuntime) -> int:
# P1 uses a common expectation of 50 until archetypes have an explicit
# expected-quality field. Quality sensitivity is a separate concept.
var expectation := 50
return clampi(service.quality_snapshot + FacilityPricing.satisfaction_modifier(
service.policy_snapshot, service.quality_snapshot, expectation
), 0, 100)
func _release_closed_business_queues(absolute_minute: int) -> void:
for facility: FacilityInstance in facility_service.get_instances():
if facility.operator_open and facility.is_within_opening_hours(absolute_minute):
continue
for request: ServiceRequest in facility_service.get_queue(facility.instance_id):
_release_closed_business_request(facility, request, absolute_minute)
for guest: GuestRuntime in guest_service.get_guests():
if guest.state != GuestRuntime.State.MOVING:
continue
var facility := facility_service.get_instance(guest.target_facility_id)
if facility == null or (facility.operator_open and facility.is_within_opening_hours(absolute_minute)):
continue
var transition := guest_service.transition_guest(guest.guest_id, GuestRuntime.State.REPLANNING, absolute_minute, &"FACILITY_CLOSED")
if bool(transition.get(&"ok", false)):
guest.target_facility_id = &""
guest.path_handle = null
func _release_closed_business_request(facility: FacilityInstance, request: ServiceRequest, minute: int) -> void:
# Validate the entire group before any queue/state signals become observable.
for guest_id: StringName in request.guest_ids:
var guest := guest_service.get_guest(guest_id)
if guest == null:
continue
if guest.state != GuestRuntime.State.QUEUING or guest.queue_request_id != request.request_id or guest.target_facility_id != facility.instance_id:
return
if guest.transition_tick == minute and guest.transitions_this_tick >= GuestService.MAX_TRANSITIONS_PER_TICK:
return
# A group releases its one reservation and queue atomically. A transition-budget
# failure retries next minute, without taking patience or dropping any stock.
var before_inventory := inventory_service.create_snapshot()
var before_facility := facility_service.create_snapshot()
var before_guest := guest_service.create_snapshot()
var before_events := domain_events.create_snapshot()
var before_ids := id_generator.create_snapshot()
var released := false
var release := func(_guest_id: StringName = &"", _facility_id: StringName = &"", _request_id: StringName = &"") -> Dictionary:
for reservation_id: StringName in request.reserved_inventory_ids:
var result := inventory_service.release_reservation(reservation_id, StringName("business.close.%s.%s" % [request.request_id, reservation_id]), minute)
if not bool(result.get(&"ok", false)):
return result
var result := facility_service.leave_queue(facility.instance_id, request.request_id, &"FACILITY_CLOSED")
return {&"ok": result.is_success()}
var ok := true
for guest_id: StringName in request.guest_ids:
if not guest_service.has_guest(guest_id):
continue
var result: Dictionary = guest_service.release_queue_for_replanning(guest_id, minute, release if not released else Callable())
if not bool(result.get(&"ok", false)):
ok = false
break
released = true
if ok and not released:
ok = bool(release.call().get(&"ok", false))
if not ok:
inventory_service.restore_snapshot(before_inventory)
facility_service.restore_snapshot(before_facility)
guest_service.restore_snapshot(before_guest)
domain_events.restore_snapshot(before_events)
id_generator.restore_snapshot(before_ids)
+2 -2
View File
@@ -20,8 +20,8 @@ required_roles = Array[StringName]([&"cook", &"server", &"attendant"])
primary_skill = &"cooking"
capacity = 12
queue_capacity = 24
base_service_minutes = 15
base_service_minutes = 45
base_quality = 55
base_price = 35
base_price = 25
inventory_inputs = {&"ingredient/basic_meal": 1}
tags = Array[StringName]([&"dining", &"social"])
+3 -3
View File
@@ -11,7 +11,7 @@ description = "全ての客のチェックインと精算を処理する、境
category = &"guest_service"
service_types = Array[StringName]([&"check_in", &"check_out"])
default_service_type = &"check_in"
service_profiles = {&"check_in": {&"duration_minutes": 8, &"base_quality": 60, &"base_price": 20, &"capacity_units": 1, &"need_effects": {&"sleep": 0}}, &"check_out": {&"duration_minutes": 6, &"base_quality": 60, &"base_price": 0, &"capacity_units": 1, &"need_effects": {&"sleep": 0}}}
service_profiles = {&"check_in": {&"duration_minutes": 20, &"base_quality": 60, &"base_price": 0, &"capacity_units": 1, &"need_effects": {&"sleep": 0}}, &"check_out": {&"duration_minutes": 6, &"base_quality": 60, &"base_price": 0, &"capacity_units": 1, &"need_effects": {&"sleep": 0}}}
zone = 2
layer = 0
footprint = Vector2i(4, 2)
@@ -30,9 +30,9 @@ staff_max = 3
required_roles = Array[StringName]([&"receptionist"])
capacity = 8
queue_capacity = 16
base_service_minutes = 8
base_service_minutes = 20
base_quality = 60
base_price = 20
base_price = 0
opening_schedule = {&"morning": true, &"day": true, &"night": true}
policy_schema = {&"price_mode": {&"type": &"enum", &"default": &"standard"}, &"vip_priority": {&"type": &"bool", &"default": false}}
cleanliness_decay_per_service = 1
+1 -1
View File
@@ -20,5 +20,5 @@ capacity = 2
queue_capacity = 4
base_service_minutes = 720
base_quality = 50
base_price = 80
base_price = 120
tags = Array[StringName]([&"lodging", &"quiet"])
+43 -6
View File
@@ -4,6 +4,7 @@ const OpeningGuideScript := preload("res://scripts/ui/inn_opening_guide.gd")
const OperatingLedgerPanelScript := preload("res://scripts/ui/operating_ledger_panel.gd")
const StaffManagementPanelScript := preload("res://scripts/ui/staff_management_panel.gd")
const SupplyManagementPanelScript := preload("res://scripts/ui/supply_management_panel.gd")
const BusinessManagementPanelScript := preload("res://scripts/ui/business_management_panel.gd")
const WorldPreviewScript := preload("res://scenes/main/world_preview.gd")
const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres")
const MANUAL_SAVE_PATH := "user://saves/manual_ui"
@@ -130,6 +131,8 @@ var navigation_buttons: Dictionary = {}
var opening_guide: Control
var operating_ledger_panel: VBoxContainer
var supply_management_panel: VBoxContainer
var business_management_panel: VBoxContainer
var _business_panel_open := false
var _supply_panel_open := false
var operations_controls: VBoxContainer
var _ledger_panel_open := false
@@ -394,6 +397,7 @@ func _create_navigation_rail() -> Control:
[&"staff", "従業員"],
[&"facility", "施設"],
[&"product", "商品"],
[&"business", "営業"],
[&"explore", "探索"],
[&"investment", "投資"],
[&"ledger", "帳簿"],
@@ -404,7 +408,7 @@ func _create_navigation_rail() -> Control:
button.custom_minimum_size.y = 46
button.toggle_mode = true
button.add_theme_font_size_override("font_size", 12)
_apply_button_icon(button, visual_theme.navigation_icon(mode) if visual_theme != null else null, 16)
_apply_button_icon(button, visual_theme.navigation_icon(&"ledger" if mode == &"business" else mode) if visual_theme != null else null, 16)
navigation_buttons[mode] = button
column.add_child(button)
var nav_spacer := Control.new()
@@ -540,6 +544,12 @@ func _create_side_panel() -> Control:
supply_management_panel.action_requested.connect(_activate_management_mode)
supply_management_panel.visible = false
root_column.add_child(supply_management_panel)
business_management_panel = BusinessManagementPanelScript.new()
business_management_panel.configure(_runtime, _apply_button_style)
business_management_panel.status_requested.connect(_on_staff_management_status)
business_management_panel.action_requested.connect(_activate_management_mode)
business_management_panel.visible = false
root_column.add_child(business_management_panel)
return panel
@@ -640,11 +650,13 @@ func _set_management_panel_open(open: bool) -> void:
_set_staff_management_panel_open(false)
_set_ledger_panel_open(false)
_set_supply_panel_open(false)
_set_business_panel_open(false)
func _activate_management_mode(mode: StringName) -> void:
_set_management_panel_open(mode != &"facility")
_set_supply_panel_open(mode == &"product")
_set_business_panel_open(mode == &"business")
_set_ledger_panel_open(mode == &"ledger")
for raw_mode: Variant in navigation_buttons:
(navigation_buttons[raw_mode] as Button).set_pressed_no_signal(StringName(raw_mode) == mode)
@@ -655,6 +667,8 @@ func _activate_management_mode(mode: StringName) -> void:
_set_build_palette_open(true)
_set_staff_candidate_panel_open(false)
_set_status("建築する施設を下のパレットから選んでください。", true, true)
&"business":
_set_status("施設ごとの料金と新規受付の開閉を設定できます。", true, true)
&"product":
_set_status("倉庫への仕入れ、厨房の自動生産、施設への配送を確認できます。", true, true)
&"explore":
@@ -669,14 +683,15 @@ func _set_ledger_panel_open(open: bool) -> void:
_ledger_panel_open = open
if open:
_set_supply_panel_open(false)
_set_business_panel_open(false)
if operating_ledger_panel != null:
operating_ledger_panel.visible = open
if open:
operating_ledger_panel.refresh()
if operations_controls != null:
operations_controls.visible = not open and not _supply_panel_open
operations_controls.visible = not open and not _supply_panel_open and not _business_panel_open
if dashboard_summary_label != null:
dashboard_summary_label.visible = not open and not _supply_panel_open
dashboard_summary_label.visible = not open and not _supply_panel_open and not _business_panel_open
if open:
_set_staff_candidate_panel_open(false)
_set_staff_management_panel_open(false)
@@ -685,6 +700,7 @@ func _set_ledger_panel_open(open: bool) -> void:
func _set_supply_panel_open(open: bool) -> void:
_supply_panel_open = open
if open:
_set_business_panel_open(false)
_set_ledger_panel_open(false)
_set_staff_candidate_panel_open(false)
_set_staff_management_panel_open(false)
@@ -693,9 +709,26 @@ func _set_supply_panel_open(open: bool) -> void:
if open:
supply_management_panel.refresh()
if operations_controls != null:
operations_controls.visible = not open and not _ledger_panel_open
operations_controls.visible = not open and not _ledger_panel_open and not _business_panel_open
if dashboard_summary_label != null:
dashboard_summary_label.visible = not open and not _ledger_panel_open
dashboard_summary_label.visible = not open and not _ledger_panel_open and not _business_panel_open
func _set_business_panel_open(open: bool) -> void:
_business_panel_open = open
if open:
_set_ledger_panel_open(false)
_set_supply_panel_open(false)
_set_staff_candidate_panel_open(false)
_set_staff_management_panel_open(false)
if business_management_panel != null:
business_management_panel.visible = open
if open:
business_management_panel.refresh()
if operations_controls != null:
operations_controls.visible = not open and not _ledger_panel_open and not _supply_panel_open
if dashboard_summary_label != null:
dashboard_summary_label.visible = not open and not _ledger_panel_open and not _supply_panel_open
func _on_opening_action(action: StringName) -> void:
@@ -851,6 +884,7 @@ func _set_staff_management_panel_open(open: bool) -> void:
if open:
_set_ledger_panel_open(false)
_set_supply_panel_open(false)
_set_business_panel_open(false)
_set_staff_candidate_panel_open(false)
_set_management_panel_open(true)
staff_management_panel.refresh()
@@ -873,6 +907,7 @@ func _set_staff_candidate_panel_open(open: bool) -> void:
if open:
_set_ledger_panel_open(false)
_set_supply_panel_open(false)
_set_business_panel_open(false)
_set_staff_management_panel_open(false)
_set_management_panel_open(true)
_set_status("雇用候補を選んでください。契約金と日給は確定前に表示されます。", true, true)
@@ -1032,7 +1067,7 @@ func _clear_build_selection(announce := true) -> void:
func _clear_active_tool() -> void:
if _supply_panel_open:
if _supply_panel_open or _business_panel_open:
_set_management_panel_open(false)
return
if _staff_management_panel_open:
@@ -1734,6 +1769,8 @@ func _load_manual() -> void:
staff_management_panel.refresh()
if supply_management_panel != null:
supply_management_panel.refresh()
if business_management_panel != null:
business_management_panel.refresh()
_selected_entity_id = &""
_selected_entity_kind = &""
world_preview.selected_entity_id = &""
+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": "積込できませんでした。再手配を待っています。",
+209
View File
@@ -0,0 +1,209 @@
extends SceneTree
var _checks := 0
var _failures := 0
var _runtime: Variant
var _clock: Variant
var _state: Variant
var _cafe_id: StringName = &""
var _serial := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("Business closure: real tick order, grouped queues and scheduled policies")
_test_last_patience_minute()
_test_group_preflight_and_retry()
_test_scheduled_closure_restore()
_test_outside_opening_hours_rejects_new_request()
_clear_fixture()
print("Business closure: %d checks, %d failures" % [_checks, _failures])
quit(1 if _failures else 0)
func _test_last_patience_minute() -> void:
if not _fixture(8101):
return
_queue([&"last_minute"], &"last.queue", 1)
var available := _available_meals()
_expect(_policy({&"business_open": false}).is_success(), "close while a guest has only one patience minute")
_clock.advance_minutes(1)
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"last_minute")
_expect(guest != null and guest.patience_remaining == 1, "actual tick releases closed queue before consuming its final patience minute")
_expect(guest.queue_request_id.is_empty(), "the guest no longer owns the closed request")
_expect(_runtime.facility_service.get_request(&"last.queue") == null, "closed queue request is removed")
_expect(_available_meals() == available + 1, "the finite meal reservation returns to cafeteria stock")
_expect(_closure_events(&"last_minute") == 1, "exactly one closure redecision event is published")
_expect(not _has_abandonment(&"last_minute"), "closure never publishes a patience-exhausted abandonment")
func _test_group_preflight_and_retry() -> void:
if not _fixture(8102):
return
_queue([&"group_a", &"group_b"], &"group.queue", 1)
_expect(_policy({&"business_open": false}).is_success(), "close a two-guest queued request")
var blocked_minute: int = _clock.absolute_minute + 1
var observations := {&"before": {}, &"after": {}, &"closure_callbacks": 0}
_runtime.domain_events.subscribe(&"GUEST_STATE_CHANGED", &"test.business.closure", func(event: SimulationDomainEvent):
if StringName(event.payload.get(&"reason", &"")) == &"FACILITY_CLOSED":
observations[&"closure_callbacks"] += 1
)
# Consume one member's budget immediately before the real queue phase. This
# reproduces a same-minute transition limit without moving any clock backwards.
_clock.register_tick_handler(_clock.TickPhase.FACILITY_QUEUES, &"aa.business.budget", func(minute: int):
if minute != blocked_minute:
return
var second: GuestRuntime = _runtime.guest_service.get_guest(&"group_b")
second.transition_tick = minute
second.transitions_this_tick = GuestService.MAX_TRANSITIONS_PER_TICK
observations[&"before"] = _runtime.create_snapshot()
)
_clock.register_tick_handler(_clock.TickPhase.FACILITY_QUEUES, &"zz.business.budget", func(minute: int):
if minute == blocked_minute:
observations[&"after"] = _runtime.create_snapshot()
)
var available := _available_meals()
_clock.advance_minutes(1)
_expect(not observations[&"before"].is_empty() and observations[&"before"] == observations[&"after"], "group budget failure leaves the entire queue-phase snapshot unchanged")
_expect(observations[&"closure_callbacks"] == 0, "no first-member state event escapes a rejected group release")
_expect(_runtime.facility_service.get_queue(_cafe_id).size() == 1 and _available_meals() == available, "deferred group retains one request and its exact stock reservation")
for id: StringName in [&"group_a", &"group_b"]:
var waiting: GuestRuntime = _runtime.guest_service.get_guest(id)
_expect(waiting.state == GuestRuntime.State.QUEUING and waiting.patience_remaining == 1, "deferred closed group preserves %s state and patience" % id)
_clock.advance_minutes(1)
_expect(observations[&"closure_callbacks"] == 2, "both members publish closure redecision on the next available minute")
_expect(_runtime.facility_service.get_queue(_cafe_id).is_empty() and _available_meals() == available + 1, "group retry releases its shared request and inventory once")
for id: StringName in [&"group_a", &"group_b"]:
var released: GuestRuntime = _runtime.guest_service.get_guest(id)
_expect(released != null and released.queue_request_id.is_empty() and released.patience_remaining == 1, "successful retry preserves %s patience" % id)
_expect(not _has_abandonment(id), "group retry never abandons %s through a patience failure" % id)
func _test_scheduled_closure_restore() -> void:
if not _fixture(8103):
return
_queue([&"scheduled"], &"scheduled.queue", 3)
var effective_at: int = _clock.absolute_minute + 1
_expect(_cmd(&"set_facility_policy", {
&"facility_id": _cafe_id, &"patch": {&"business_open": false}, &"effective_at": effective_at,
}).is_success(), "schedule closure at the next minute boundary")
_expect(_runtime.facility_service.get_instance(_cafe_id).operator_open, "a future closure does not change current operator intent")
var saved: Dictionary = _runtime.create_snapshot()
_clock.advance_minutes(1)
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"scheduled")
_expect(not _runtime.facility_service.get_instance(_cafe_id).operator_open, "scheduled closure applies in the facility phase")
_expect(guest != null and guest.patience_remaining == 3 and guest.queue_request_id.is_empty(), "scheduled closure releases its waiting guest before patience advances")
var completed: Dictionary = _runtime.create_snapshot()
_expect(_runtime.restore_snapshot(saved), "pending closure plus reserved guest request restores")
_clock.advance_minutes(1)
_expect(_runtime.create_snapshot() == completed, "restored scheduled closure reproduces the exact state and event history")
func _test_outside_opening_hours_rejects_new_request() -> void:
if not _fixture(8104):
return
_spawn(&"before_open")
_expect(_runtime.facility_service.set_opening_hours(_cafe_id, 600, 1_200, _clock.absolute_minute).is_success(), "set opening hours beginning after the current minute")
var stock: Dictionary = _runtime.inventory_service.create_snapshot()
var guests: Dictionary = _runtime.guest_service.create_snapshot()
var facilities: Dictionary = _runtime.facility_service.create_snapshot()
var result := _cmd(&"create_service_request", {&"facility_id": _cafe_id, &"request_id": &"outside.queue", &"guest_ids": [&"before_open"]})
_expect(not result.is_success() and result.details.get(&"reason", &"") == &"FACILITY_CLOSED", "outside-hours request is rejected as closed")
_expect(_runtime.inventory_service.create_snapshot() == stock, "outside-hours rejection does not reserve inventory")
_expect(_runtime.guest_service.create_snapshot() == guests and _runtime.facility_service.create_snapshot() == facilities, "outside-hours rejection cannot enqueue or change the guest")
func _fixture(seed_value: int) -> bool:
_clear_fixture()
_clock = load("res://autoload/game_clock.gd").new()
_state = load("res://autoload/game_state.gd").new()
_runtime = load("res://autoload/simulation_runtime.gd").new()
_clock.reset(1, 6, 0)
_state.reset()
var initialized: bool = _runtime.initialize(_clock, _state, seed_value).get(&"valid", false)
_expect(initialized, "closure runtime initializes")
if not initialized:
return false
_clock.set_paused(true)
for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)]]:
_expect(_cmd(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "closure fixture starts %s construction" % row[0])
_clock.advance_minutes(180)
_cafe_id = &""
for facility: FacilityInstance in _runtime.facility_service.get_instances():
if facility.definition_id == &"cafeteria":
_cafe_id = facility.instance_id
_expect(not _cafe_id.is_empty(), "cafeteria construction completes through ordinary ticks")
if _cafe_id.is_empty():
return false
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_cafe_id)
_expect(_cmd(&"add_inventory_stack", {&"owner_inventory_id": cafe.local_inventory_id, &"item_id": SupplyCatalog.BASIC_MEAL, &"quantity": 4}).is_success(), "finite meal fixture is available to reserve")
return true
func _queue(guest_ids: Array[StringName], request_id: StringName, patience: int) -> void:
for id: StringName in guest_ids:
_spawn(id)
_expect(_cmd(&"create_service_request", {
&"facility_id": _cafe_id, &"request_id": request_id, &"guest_ids": guest_ids,
&"options": {&"initial_patience": patience},
}).is_success(), "real guest actors own the queued meal reservation")
func _spawn(guest_id: StringName) -> void:
_expect(_cmd(&"spawn_guest", {
&"guest_id": guest_id, &"guest_type_id": &"traveler", &"gold": 200,
&"options": {&"current_cell": Vector3i.ZERO, &"planned_exit_at": _clock.absolute_minute + 240},
}).is_success(), "spawn closure guest %s" % guest_id)
_runtime.guest_service.transition_guest(guest_id, GuestRuntime.State.ENTERING, _clock.absolute_minute)
_runtime.guest_service.transition_guest(guest_id, GuestRuntime.State.DECIDING, _clock.absolute_minute)
func _closure_events(guest_id: StringName) -> int:
var count := 0
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
if event.entity_ids.has(guest_id) and StringName(event.payload.get(&"reason", &"")) == &"FACILITY_CLOSED":
count += 1
return count
func _has_abandonment(guest_id: StringName) -> bool:
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
if not event.entity_ids.has(guest_id):
continue
if event.event_type == &"GUEST_QUEUE_ABANDONED" or StringName(event.payload.get(&"reason", &"")) == &"PATIENCE_EXHAUSTED":
return true
return false
func _available_meals() -> int:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_cafe_id)
return _runtime.inventory_service.available_quantity(cafe.local_inventory_id, SupplyCatalog.BASIC_MEAL)
func _policy(patch: Dictionary) -> CommandResult:
return _cmd(&"set_facility_policy", {&"facility_id": _cafe_id, &"patch": patch})
func _cmd(type: StringName, payload: Dictionary) -> CommandResult:
_serial += 1
return _runtime.submit_command(type, payload, &"player", &"", 75, -1, StringName("business.closure.%d" % _serial))
func _expect(ok: bool, message: String) -> void:
_checks += 1
if not ok:
_failures += 1
push_error(message)
func _clear_fixture() -> void:
if _runtime != null:
_runtime.free()
_clock.free()
_state.free()
_runtime = null
_clock = null
_state = null
@@ -0,0 +1 @@
uid://dv11kpqhqkmtg
+160
View File
@@ -0,0 +1,160 @@
extends SceneTree
var _checks := 0
var _failures := 0
var _runtime: Variant
var _clock: Variant
var _state: Variant
var _ids: Dictionary = {}
var _serial := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_clock = load("res://autoload/game_clock.gd").new()
_state = load("res://autoload/game_state.gd").new()
_runtime = load("res://autoload/simulation_runtime.gd").new()
_clock.reset(1, 6, 0)
_state.reset()
_expect(_runtime.initialize(_clock, _state, 4405).get(&"valid", false), "runtime initializes")
_clock.set_paused(true)
for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)]]:
_expect(_cmd(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "build %s" % row[0])
_clock.advance_minutes(180)
_expect(_cmd(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-8, 2, 0)}).is_success(), "room builds on completed reception")
_clock.advance_minutes(60)
for facility: FacilityInstance in _runtime.facility_service.get_instances():
_ids[facility.definition_id] = facility.instance_id
_expect(_ids.size() == 3, "three real construction jobs complete")
if _ids.size() < 3:
_finish()
return
_catalog_and_report()
_immutable_sale()
_closed_queue()
_save_and_candidates()
_finish()
func _catalog_and_report() -> void:
var report: Dictionary = _runtime.get_business_management_report()
_expect(report[&"modes"].size() == 5 and report[&"facilities"].size() == 3, "five price tiers and completed facilities are exposed")
for row: Dictionary in report[&"facilities"]:
if row[&"facility_id"] == _ids[&"reception"]:
_expect(row[&"price"] == 0 and not row[&"can_price"], "reception has no direct charge")
elif row[&"facility_id"] == _ids[&"standard_room"]:
_expect(row[&"base_price"] == 120, "new room uses documented 120 G rate")
else:
_expect(row[&"base_price"] == 25, "new cafeteria uses documented 25 G rate")
var before: Dictionary = _runtime.create_snapshot()
report[&"facilities"][0][&"price"] = 99999
_expect(_runtime.create_snapshot() == before, "read model is detached from authority")
var reception: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"reception"])
reception.base_price = 20 # A legacy v7 facility keeps its stored fields.
_expect(_cmd(&"register_guest_wallet", {&"guest_id": &"checkin", &"gold": 50}).is_success(), "wallet for reception regression")
_expect(_cmd(&"create_service_request", {&"facility_id": reception.instance_id, &"request_id": &"free_checkin", &"guest_ids": [&"checkin"]}).is_success(), "legacy reception accepts new check-in")
_expect(_runtime.facility_service.get_request(&"free_checkin").quoted_price == 0, "new quotes at legacy reception are free")
func _immutable_sale() -> void:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(_cmd(&"set_facility_conditions", {&"facility_id": cafe.instance_id, &"conditions": {&"active_staff_ids": [&"cook", &"server"]}}).is_success(), "staff facility for settlement test")
_expect(_cmd(&"add_inventory_stack", {&"owner_inventory_id": cafe.local_inventory_id, &"item_id": SupplyCatalog.BASIC_MEAL, &"quantity": 3}).is_success(), "provide finite meal fixture")
_expect(_cmd(&"register_guest_wallet", {&"guest_id": &"payer", &"gold": 200}).is_success(), "payer wallet")
_expect(_policy(cafe.instance_id, {&"price_mode": &"expensive"}).is_success(), "set expensive tier")
_expect(_cmd(&"create_service_request", {&"facility_id": cafe.instance_id, &"request_id": &"meal.expensive", &"guest_ids": [&"payer"], &"options": {&"service_minutes": 3}}).is_success(), "quote an expensive meal")
_expect(_runtime.facility_service.get_request(&"meal.expensive").quoted_price == 38, "25 G at 150% rounds half up to 38 G")
_expect(_policy(cafe.instance_id, {&"price_mode": &"charitable"}).is_success(), "price changes before service starts")
_clock.advance_minutes(1)
var active: Array = _runtime.facility_service.get_active_services(cafe.instance_id)
_expect(active.size() == 1 and active[0].price_snapshot == 38, "queue retains old quote and policy")
var checkpoint: Dictionary = _runtime.create_snapshot()
var cash: int = _state.get_resource(&"gold")
_expect(_policy(cafe.instance_id, {&"business_open": false}).is_success(), "operator closes during meal")
var inventory: Dictionary = _runtime.inventory_service.create_snapshot()
_expect(not _cmd(&"create_service_request", {&"facility_id": cafe.instance_id, &"guest_ids": [&"payer"]}).is_success(), "closed business rejects new reservations")
_expect(_runtime.inventory_service.create_snapshot() == inventory, "rejected closed request consumes no stock")
_clock.advance_minutes(3)
_expect(_state.get_resource(&"gold") == cash + 38 and _runtime.economy_service.get_balance(&"GUEST_WALLET:payer") == 162, "closing still finishes and charges existing quote once")
_expect(_runtime.facility_service.get_active_services(cafe.instance_id).is_empty(), "closed facility finishes its existing service")
_expect(_runtime.restore_snapshot(checkpoint), "active quote and policy restore together")
_expect(_policy(cafe.instance_id, {&"business_open": false}).is_success(), "replay closure")
_clock.advance_minutes(3)
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:payer") == 162, "restored meal also settles once")
_expect(_runtime.economy_service.validate_ledger().get(&"ok", false), "settlement ledger reconciles")
var report: Dictionary = _runtime.get_business_management_report()
for row: Dictionary in report[&"facilities"]:
if row[&"facility_id"] == cafe.instance_id:
_expect(row[&"today_revenue"] == 38 and row[&"price"] == 15 and not row[&"business_open"], "report shows actual sale, future price, and closure separately")
func _closed_queue() -> void:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(_policy(cafe.instance_id, {&"business_open": true}).is_success(), "reopen")
_expect(_cmd(&"spawn_guest", {&"guest_id": &"queue_guest", &"guest_type_id": &"traveler", &"gold": 200, &"options": {&"current_cell": Vector3i.ZERO, &"planned_exit_at": _clock.absolute_minute + 240}}).is_success(), "spawn real guest actor")
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"queue_guest")
_runtime.guest_service.transition_guest(guest.guest_id, GuestRuntime.State.ENTERING, _clock.absolute_minute)
_runtime.guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, _clock.absolute_minute)
_expect(_cmd(&"create_service_request", {&"facility_id": cafe.instance_id, &"request_id": &"closing.queue", &"guest_ids": [guest.guest_id]}).is_success(), "guest owns queued meal reservation")
var patience: int = guest.patience_remaining
var available: int = _runtime.inventory_service.available_quantity(cafe.local_inventory_id, SupplyCatalog.BASIC_MEAL)
_expect(_policy(cafe.instance_id, {&"business_open": false}).is_success(), "close queued facility")
# Same-tick transition budget may already be exhausted; the release retries.
_runtime.call("_release_closed_business_queues", _clock.absolute_minute + 1)
guest = _runtime.guest_service.get_guest(&"queue_guest")
_expect(guest.queue_request_id.is_empty() and guest.state == GuestRuntime.State.DECIDING, "closure releases guest for a fresh decision")
_expect(guest.patience_remaining == patience, "closing does not fake a patience failure")
_expect(_runtime.facility_service.get_queue(cafe.instance_id).is_empty() and _runtime.inventory_service.available_quantity(cafe.local_inventory_id, SupplyCatalog.BASIC_MEAL) == available + 1, "queue and reserved stock release together")
# Align every service clock before save validation.
_clock.advance_minutes(1)
func _save_and_candidates() -> void:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(_policy(cafe.instance_id, {&"business_open": true, &"price_mode": &"standard"}).is_success(), "reopen at standard rate")
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"queue_guest")
if guest != null:
var low_candidates: Array = _runtime.call("_project_guest_candidates", guest.guest_id)
var low: Dictionary = {}
for row: Dictionary in low_candidates:
if row[&"facility_id"] == cafe.instance_id: low = row
_policy(cafe.instance_id, {&"price_mode": &"exorbitant"})
var high: Dictionary = {}
for row: Dictionary in _runtime.call("_project_guest_candidates", guest.guest_id):
if row[&"facility_id"] == cafe.instance_id: high = row
_expect(not low.is_empty() and not high.is_empty() and low[&"estimated_total_gold"] == 25 and high[&"estimated_total_gold"] == 63, "guest candidate quotes track visible price tier")
if not low.is_empty() and not high.is_empty():
_expect(_runtime.guest_service.evaluate_candidate(guest.guest_id, low)[&"utility"] > _runtime.guest_service.evaluate_candidate(guest.guest_id, high)[&"utility"], "higher price lowers same guest's utility")
var service := ServiceInstance.new()
service.quality_snapshot = 55
service.policy_snapshot = {&"price_mode": &"charitable"}
var satisfied: int = _runtime.call("_service_satisfaction_sample", service, guest)
service.policy_snapshot = {&"price_mode": &"exorbitant"}
_expect(satisfied > int(_runtime.call("_service_satisfaction_sample", service, guest)), "price policy affects satisfaction sample")
var save_path := "user://tests/business_%d/policy" % OS.get_process_id()
_expect(_runtime.save_to_path(save_path).get(&"ok", false), "business policies save to an isolated disk slot")
var price_before: int = _runtime.get_business_management_report()[&"facilities"].size()
_expect(_runtime.load_from_path(save_path).get(&"ok", false), "business policies and guest ownership load")
_expect(_runtime.get_business_management_report()[&"facilities"].size() == price_before, "facility reports remain available after load")
cafe = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(cafe.operator_open and cafe.policy_current[&"price_mode"] == &"exorbitant", "disk restore preserves open state and price mode")
var before: Dictionary = _runtime.facility_service.create_snapshot()
_expect(not _policy(cafe.instance_id, {&"price_mode": &"unknown", &"business_open": false}).is_success(), "invalid tier rejects entire combined policy")
_expect(_runtime.facility_service.create_snapshot() == before, "invalid patch cannot partially close business")
func _policy(id: StringName, patch: Dictionary) -> CommandResult:
return _cmd(&"set_facility_policy", {&"facility_id": id, &"patch": patch})
func _cmd(type: StringName, payload: Dictionary) -> CommandResult:
_serial += 1
return _runtime.submit_command(type, payload, &"player", &"", 75, -1, StringName("business.test.%d" % _serial))
func _expect(ok: bool, message: String) -> void:
_checks += 1
if not ok:
_failures += 1
push_error(message)
func _finish() -> void:
print("Business operations: %d checks, %d failures" % [_checks, _failures])
_runtime.free()
_clock.free()
_state.free()
quit(1 if _failures else 0)
@@ -0,0 +1 @@
uid://xnbsrxgxnvsx
@@ -0,0 +1,240 @@
extends RefCounted
## Frozen v1 encoder from commit 627b200, kept only as the byte-compatibility
## oracle and unoptimized benchmark baseline. Do not update with production changes.
##
## Dictionary insertion order never affects the result. Type tags and byte
## lengths make the canonical form unambiguous, and Array order remains
## significant. Runtime Objects and other process-local values are rejected;
## callers must first export them to Dictionaries, Arrays and value types.
const FORMAT_VERSION := 1
const MAX_NESTING_DEPTH := 128
static func compute(value: Variant) -> String:
var canonical := canonical_bytes(value)
if canonical.is_empty():
return ""
var context := HashingContext.new()
if context.start(HashingContext.HASH_SHA256) != OK:
push_error("Could not initialize the simulation digest hash")
return ""
context.update(canonical)
return context.finish().hex_encode()
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):
return PackedByteArray()
return output
static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -> bool:
if depth > MAX_NESTING_DEPTH:
push_error("SimulationDigest data exceeds the maximum nesting depth")
return false
match typeof(value):
TYPE_NIL:
_append_ascii(output, "n")
TYPE_BOOL:
_append_ascii(output, "b1" if bool(value) else "b0")
TYPE_INT:
_append_ascii(output, "i%d;" % int(value))
TYPE_FLOAT:
_append_ascii(output, "f")
return _append_float64(output, float(value))
TYPE_STRING:
_append_sized_bytes(output, "s", String(value).to_utf8_buffer())
TYPE_STRING_NAME:
_append_sized_bytes(output, "q", String(value).to_utf8_buffer())
TYPE_VECTOR2:
var vector: Vector2 = value
_append_ascii(output, "v2")
return _append_float64(output, vector.x) and _append_float64(output, vector.y)
TYPE_VECTOR2I:
var vector: Vector2i = value
_append_ascii(output, "w2")
_append_ascii(output, "i%d;i%d;" % [vector.x, vector.y])
TYPE_RECT2:
var rect: Rect2 = value
_append_ascii(output, "r2")
return (
_append_float64(output, rect.position.x)
and _append_float64(output, rect.position.y)
and _append_float64(output, rect.size.x)
and _append_float64(output, rect.size.y)
)
TYPE_RECT2I:
var rect: Rect2i = value
_append_ascii(output, "x2")
_append_ascii(output, "i%d;i%d;i%d;i%d;" % [rect.position.x, rect.position.y, rect.size.x, rect.size.y])
TYPE_VECTOR3:
var vector: Vector3 = value
_append_ascii(output, "v3")
return (
_append_float64(output, vector.x)
and _append_float64(output, vector.y)
and _append_float64(output, vector.z)
)
TYPE_VECTOR3I:
var vector: Vector3i = value
_append_ascii(output, "w3")
_append_ascii(output, "i%d;i%d;i%d;" % [vector.x, vector.y, vector.z])
TYPE_VECTOR4:
var vector: Vector4 = value
_append_ascii(output, "v4")
return (
_append_float64(output, vector.x)
and _append_float64(output, vector.y)
and _append_float64(output, vector.z)
and _append_float64(output, vector.w)
)
TYPE_VECTOR4I:
var vector: Vector4i = value
_append_ascii(output, "w4")
_append_ascii(output, "i%d;i%d;i%d;i%d;" % [vector.x, vector.y, vector.z, vector.w])
TYPE_COLOR:
var color: Color = value
_append_ascii(output, "c")
return (
_append_float64(output, color.r)
and _append_float64(output, color.g)
and _append_float64(output, color.b)
and _append_float64(output, color.a)
)
TYPE_ARRAY:
return _encode_array(value, output, depth)
TYPE_DICTIONARY:
return _encode_dictionary(value, output, depth)
TYPE_PACKED_BYTE_ARRAY:
_append_sized_bytes(output, "pb", value)
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
return _encode_packed_int_array(value, output)
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
return _encode_packed_float_array(value, output)
TYPE_PACKED_STRING_ARRAY:
return _encode_packed_string_array(value, output)
TYPE_PACKED_VECTOR2_ARRAY:
return _encode_packed_vector2_array(value, output)
TYPE_PACKED_VECTOR3_ARRAY:
return _encode_packed_vector3_array(value, output)
TYPE_PACKED_COLOR_ARRAY:
return _encode_packed_color_array(value, output)
_:
push_error("SimulationDigest does not support Variant type %s" % type_string(typeof(value)))
return false
return true
static func _encode_array(value: Array, output: PackedByteArray, depth: int) -> bool:
_append_ascii(output, "a%d:" % value.size())
for item in value:
if not _encode_value(item, output, depth + 1):
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"])
)
_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):
return false
return true
static func _encode_packed_int_array(value: Variant, output: PackedByteArray) -> bool:
var tag := "pi32" if typeof(value) == TYPE_PACKED_INT32_ARRAY else "pi64"
_append_ascii(output, "%s%d:" % [tag, value.size()])
for item in value:
_append_ascii(output, "i%d;" % int(item))
return true
static func _encode_packed_float_array(value: Variant, output: PackedByteArray) -> bool:
var tag := "pf32" if typeof(value) == TYPE_PACKED_FLOAT32_ARRAY else "pf64"
_append_ascii(output, "%s%d:" % [tag, value.size()])
for item in value:
if not _append_float64(output, float(item)):
return false
return true
static func _encode_packed_string_array(value: PackedStringArray, output: PackedByteArray) -> bool:
_append_ascii(output, "ps%d:" % value.size())
for item in value:
_append_sized_bytes(output, "s", item.to_utf8_buffer())
return true
static func _encode_packed_vector2_array(value: PackedVector2Array, output: PackedByteArray) -> bool:
_append_ascii(output, "pv2%d:" % value.size())
for item in value:
if not _append_float64(output, item.x) or not _append_float64(output, item.y):
return false
return true
static func _encode_packed_vector3_array(value: PackedVector3Array, output: PackedByteArray) -> bool:
_append_ascii(output, "pv3%d:" % value.size())
for item in value:
if (
not _append_float64(output, item.x)
or not _append_float64(output, item.y)
or not _append_float64(output, item.z)
):
return false
return true
static func _encode_packed_color_array(value: PackedColorArray, output: PackedByteArray) -> bool:
_append_ascii(output, "pc%d:" % value.size())
for item in value:
if (
not _append_float64(output, item.r)
or not _append_float64(output, item.g)
or not _append_float64(output, item.b)
or not _append_float64(output, item.a)
):
return false
return true
static func _append_float64(output: PackedByteArray, value: float) -> bool:
if is_nan(value) or is_inf(value):
push_error("SimulationDigest rejects NaN and infinite float values")
return false
var normalized := 0.0 if value == 0.0 else value
var encoded := PackedByteArray()
encoded.resize(8)
encoded.encode_double(0, normalized)
output.append_array(encoded)
return true
static func _append_sized_bytes(output: PackedByteArray, tag: String, value: PackedByteArray) -> void:
_append_ascii(output, "%s%d:" % [tag, value.size()])
output.append_array(value)
static func _append_ascii(output: PackedByteArray, value: String) -> void:
output.append_array(value.to_utf8_buffer())
@@ -0,0 +1 @@
uid://cyhefo00b7wkr
@@ -0,0 +1,222 @@
extends SceneTree
const Digest = preload("res://scripts/core/simulation_digest.gd")
const Reference = preload("res://tests/performance/fixtures/simulation_digest_v1_reference.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("Simulation digest: v1 byte compatibility and growing-history benchmark")
_test_value_types()
_test_dictionary_layouts()
_test_mutable_values()
_test_limits_and_rejections()
_test_growing_histories()
print("Digest performance: %d checks, %d failures" % [_checks, _failures])
quit(_failures)
func _test_value_types() -> void:
var values: Array = [
null, false, true, -9223372036854775807, 9223372036854775807,
0, 0.0, -0.0, -0.125, 1.25e100, "", "厨房\u0000🌳", &"", &"運搬",
Vector2(-1.5, 2.25), Vector2i(-3, 7), Rect2(-1.5, 2.25, 3.5, 4.75),
Rect2i(-3, 7, 2, 8), Vector3(-1.5, 2.25, 3.5), Vector3i(-3, 7, 2),
Vector4(-1.5, 2.25, 3.5, 4.75), Vector4i(-3, 7, 2, 8), Color(0.25, 0.5, 0.75, 1),
[], {}, PackedByteArray([0, 127, 255]), PackedInt32Array([-3, 0, 7]),
PackedInt64Array([-9223372036854775807, 9223372036854775807]),
PackedFloat32Array([-1.25, 0.0, 2.5]), PackedFloat64Array([-1.25, -0.0, 1.25e100]),
PackedStringArray(["", "日本語", "🌳"]), PackedVector2Array([Vector2(-1, 2)]),
PackedVector3Array([Vector3(-1, 2, 3)]), PackedColorArray([Color(0.25, 0.5, 0.75, 1)]),
]
var strings: Array[String] = ["", "a", "厨房"]
var names: Array[StringName] = [&"", &"a", &"厨房"]
values.append(strings)
values.append(names)
for index in values.size():
_expect_bytes(values[index], "canonical bytes for value type %d, sample %d" % [typeof(values[index]), index])
_expect_bytes({"values": values}, "nested complete supported value matrix")
_expect(Digest.compute([1, 2, 3]) == "4e32bb2921e1b879d5b8778789d3bc7e46559d4448e95f9c2fed427d7e5c7b93", "published v1 reference digest remains unchanged")
func _test_dictionary_layouts() -> void:
var rows: Array = []
# Native Variant equality can equate String and StringName; cache buckets must
# keep their type tags apart even when the textual schemas are identical.
for index in 60:
var row := {}
var keys: Array = ["z", "aa", "日本語", "a", "\u0000", "", "001"]
if index % 2 == 1:
keys.reverse()
for key: String in keys:
row[StringName(key) if index % 3 == 0 else key] = {"index": index, "key": key}
rows.append(row)
_expect_bytes(rows, "cached String and StringName schemas preserve tags and byte order")
var mixed := {}
mixed["text"] = "String"
mixed[&"name"] = "StringName"
mixed[7] = "integer"
mixed[1.25] = "float"
mixed[Vector2i(2, 3)] = "vector"
mixed[["a", 1]] = "array key"
mixed[{"inner": "key"}] = "dictionary key"
_expect_bytes([mixed, mixed.duplicate(true)], "mixed and mutable dictionary keys use the uncached encoder")
var forward := {"a": 1, "b": 2, "ccc": 3}
var reverse := {"ccc": 3, "b": 2, "a": 1}
_expect(Digest.canonical_bytes(forward) == Digest.canonical_bytes(reverse), "canonical order ignores dictionary insertion order")
# Exceed each bound to check graceful uncached fallback, without relying on a
# process-global cache or on the hash of an incomplete key signature.
var many_schemas: Array = []
for index in 600:
many_schemas.append({"schema_%d" % index: index, "nested": {"value": index}})
_expect_bytes(many_schemas, "more than the bounded layout capacity remains byte-identical")
# These two complete key arrays collide under Godot 4.7's native hash. The
# cache must still compare complete contents, never index by hash alone.
var collision_left := {"schema.96ac180f": 1, "value": 2}
var collision_right := {"schema.c5015a12": 3, "value": 4}
if hash(collision_left.keys()) == hash(collision_right.keys()):
print(" Native layout hash-collision fixture exercised")
_expect_bytes([collision_left, collision_right, collision_left], "different key layouts with colliding native hashes remain distinct")
var many_keys := {}
for index in 80:
many_keys["key_%d" % index] = index
_expect_bytes(many_keys, "large dictionaries remain byte-identical")
_expect_bytes({"長".repeat(200): 1}, "long keys bypass the cache")
func _test_mutable_values() -> void:
var nested := {"history": [{"minute": 1, "amount": 3}], "queue": [1, 2]}
var before := Digest.compute(nested)
nested["history"][0]["amount"] = 4
nested["queue"].append(3)
_expect_bytes(nested, "nested value mutations cannot reuse stale encoded values")
_expect(Digest.compute(nested) != before, "nested mutation changes the hash")
nested.erase("queue")
nested[&"queue"] = [1, 2]
_expect_bytes(nested, "key type mutation between digest calls is observed")
var left := {"key": {"value": 1}}
var right := {"key": {"value": 2}}
_expect_bytes([left, right], "reused schema reads each dictionary's current values")
var canonical := Digest.canonical_bytes(nested)
canonical.fill(0)
_expect_bytes(nested, "caller mutation of previous output cannot corrupt another call")
func _test_limits_and_rejections() -> void:
var typed_leaf: Array[String] = ["leaf"]
_expect_bytes(_nested(typed_leaf, 127), "typed string item at exact nesting limit is valid")
_expect_bytes(_nested({"leaf": 1}, 127), "cached dictionary key at exact nesting limit is valid")
_expect_bytes(_nested([], 128), "empty container at nesting limit is valid")
var rejected: Array = [_nested(typed_leaf, 128), _nested({"leaf": 1}, 128), _nested(null, 129), NAN, INF, -INF, Vector2(NAN, 1), PackedFloat64Array([INF]), {"nested": NAN}, NodePath("runtime/object")]
var object := RefCounted.new()
rejected.append(object)
var errors_enabled := Engine.print_error_messages
Engine.print_error_messages = false
for index in rejected.size():
var legacy: PackedByteArray = Reference.canonical_bytes(rejected[index])
var current: PackedByteArray = Digest.canonical_bytes(rejected[index])
_expect(legacy.is_empty() and current.is_empty(), "unsupported or over-depth sample %d still rejects" % index)
Engine.print_error_messages = errors_enabled
func _test_growing_histories() -> void:
var reports: Array[Dictionary] = []
for history_count in [100, 1000, 5000]:
var snapshot := _history_snapshot(history_count)
_expect_bytes(snapshot, "growing-history snapshot with %d records" % history_count)
_expect(Digest.compute(snapshot) == Reference.compute(snapshot), "growing-history SHA-256 with %d records" % history_count)
# Warm both encoders, then alternate execution order. Timing is reported,
# never asserted: CPU load and editor/preview activity vary across machines.
Reference.compute(snapshot)
Digest.compute(snapshot)
var reference_samples: Array[float] = []
var optimized_samples: Array[float] = []
for sample in 5:
if sample % 2 == 0:
reference_samples.append(_measure(Reference.compute, snapshot))
optimized_samples.append(_measure(Digest.compute, snapshot))
else:
optimized_samples.append(_measure(Digest.compute, snapshot))
reference_samples.append(_measure(Reference.compute, snapshot))
reference_samples.sort()
optimized_samples.sort()
var reference_ms: float = reference_samples[2]
var optimized_ms: float = optimized_samples[2]
var report := {
"history_records": history_count,
"canonical_bytes": Digest.canonical_bytes(snapshot).size(),
"baseline_median_ms": snappedf(reference_ms, 0.001),
"optimized_median_ms": snappedf(optimized_ms, 0.001),
"speedup": snappedf(reference_ms / optimized_ms, 0.001),
}
reports.append(report)
print("DIGEST_BENCHMARK %s" % JSON.stringify(report))
print("DIGEST_PERFORMANCE_REPORT %s" % JSON.stringify({"samples_per_encoder": 5, "metric": "median wall time including SHA-256", "fixture": "synthetic event, ledger, and service histories; complete histories retained", "results": reports}))
var ids: Array[String] = []
for index in 10000:
ids.append("facility.operation.finished:%08d:reception.1:guest.%08d" % [index, index / 4])
var id_snapshot := {"version": 1, "published_ids": ids, "recent_events": []}
_expect_bytes(id_snapshot, "append-only published event ID history remains complete")
var id_reference_samples: Array[float] = []
var id_optimized_samples: Array[float] = []
for sample in 5:
id_reference_samples.append(_measure(Reference.compute, id_snapshot))
id_optimized_samples.append(_measure(Digest.compute, id_snapshot))
id_reference_samples.sort()
id_optimized_samples.sort()
print("DIGEST_EVENT_HISTORY_BENCHMARK %s" % JSON.stringify({"published_ids": ids.size(), "canonical_bytes": Digest.canonical_bytes(id_snapshot).size(), "baseline_median_ms": snappedf(id_reference_samples[2], 0.001), "optimized_median_ms": snappedf(id_optimized_samples[2], 0.001), "speedup": snappedf(id_reference_samples[2] / id_optimized_samples[2], 0.001)}))
func _history_snapshot(count: int) -> Dictionary:
var published_ids: Array[String] = []
var recent_events: Array[Dictionary] = []
var entries: Array[Dictionary] = []
var receipts: Array[Dictionary] = []
for index in count:
var event_id := "event.economy.ledger.%08d" % index
published_ids.append(event_id)
entries.append({
"entry_id": "entry.%08d" % index, "absolute_minute": index * 3,
"entry_type": "operating_expense" if index % 3 == 0 else "service_revenue",
"amount": -12 if index % 3 == 0 else 35, "account_id": "cash",
"counterparty_id": "guest.%05d" % (index % 250), "reference_id": event_id,
"metadata": {"facility_id": "cafeteria.1", "quantity": 1, "description": "基本の食事"},
})
receipts.append({"receipt_id": "receipt.%08d" % index, "minute": index * 3, "guest_id": "guest.%05d" % (index % 250), "facility_id": "cafeteria.1", "satisfied": true, "price": 35})
if index >= maxi(count - 128, 0):
recent_events.append({"event_id": event_id, "event_type": "ledger.posted", "absolute_minute": index * 3, "source_service": "economy", "entity_ids": ["cafeteria.1"], "payload": {"amount": 35, "quantity": 1}, "causation_command_id": index})
return {"version": 7, "minute": count * 3, "domain_events": {"version": 1, "published_ids": published_ids, "recent_events": recent_events}, "economy": {"version": 1, "balance": 5000 + count * 12, "entries": entries}, "operations": {"version": 1, "receipts": receipts}}
func _nested(leaf: Variant, levels: int) -> Variant:
var result: Variant = leaf
for index in levels:
result = [result]
return result
func _measure(encoder: Callable, snapshot: Dictionary) -> float:
var started := Time.get_ticks_usec()
encoder.call(snapshot)
return float(Time.get_ticks_usec() - started) / 1000.0
func _expect_bytes(value: Variant, label: String) -> void:
var expected: PackedByteArray = Reference.canonical_bytes(value)
var actual: PackedByteArray = Digest.canonical_bytes(value)
_expect(not expected.is_empty() and actual == expected, label)
func _expect(condition: bool, label: String) -> void:
_checks += 1
if condition:
print(" PASS: %s" % label)
else:
_failures += 1
push_error(" FAIL: %s" % label)
@@ -0,0 +1 @@
uid://c78f41p838v73
+216
View File
@@ -0,0 +1,216 @@
extends SceneTree
const Pricing = preload("res://scripts/facility/facility_pricing.gd")
const Facilities = preload("res://scripts/facility/facility_service.gd")
const Facility = preload("res://scripts/facility/facility_instance.gd")
const Service = preload("res://scripts/facility/service_instance.gd")
const Guests = preload("res://scripts/guest/guest_service.gd")
const Guest = preload("res://scripts/guest/guest_runtime.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("S2: Facility pricing and operating policy")
_test_prices_and_satisfaction()
_test_validation_is_atomic()
_test_locked_quotes_and_scheduled_changes()
_test_closed_service_and_opening_hours()
_test_snapshot_restore_and_legacy_policy()
_test_queue_release_is_atomic()
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _test_prices_and_satisfaction() -> void:
var rows := Pricing.modes()
_expect(rows.size() == 5, "five price modes are exposed")
var expected_prices := [15, 20, 25, 38, 63]
var expected_deltas := [10, 5, 0, -5, -20]
var expected_softened := [10, 5, 0, -3, -10]
for index: int in rows.size():
var policy := {&"price_mode": rows[index][&"id"]}
_expect(Pricing.quote(25, policy) == expected_prices[index], "25 G price follows %s policy" % rows[index][&"id"])
_expect(Pricing.satisfaction_modifier(policy, 69, 50) == expected_deltas[index], "nineteen quality points retain %s penalty" % rows[index][&"id"])
_expect(Pricing.satisfaction_modifier(policy, 70, 50) == expected_softened[index], "twenty quality points soften %s penalty" % rows[index][&"id"])
_expect(Pricing.quote(0, policy) == 0, "free service remains free for %s" % rows[index][&"id"])
rows[0][&"percent"] = 999
_expect(Pricing.modes()[0][&"percent"] == 60, "mode reports are defensive copies")
_expect(Pricing.quote(35, {&"price_mode": &"expensive"}) == 53, "half gold rounds up using integer arithmetic")
_expect(Pricing.quote(3, {"price_mode": "charitable"}) == 2, "JSON string policy keys and IDs quote correctly")
_expect(Pricing.quote(120, {}) == 120, "missing legacy price mode defaults to standard")
_expect(Pricing.quote(-20, {}) == 0, "negative base quotations clamp to zero")
func _test_validation_is_atomic() -> void:
var facilities := _facilities()
var before := facilities.create_snapshot()
for invalid: Variant in [&"premium", "", 1, false, {}, []]:
var result := facilities.set_policy(&"inn", {&"price_mode": invalid, &"vip_priority": true})
_expect(not result.is_success(), "unsupported price mode is rejected: %s" % str(invalid))
_expect(facilities.create_snapshot() == before, "invalid price patch is atomic: %s" % str(invalid))
for invalid: Variant in [0, 1, "false", null]:
_expect(not facilities.set_policy(&"inn", {&"business_open": invalid}, 25).is_success(), "business_open requires bool: %s" % str(invalid))
_expect(facilities.create_snapshot() == before, "invalid future policy cannot replace pending state")
var nested := {&"vip_priority": true, &"custom": {&"tags": [&"quiet"]}}
_expect(facilities.set_policy(&"inn", nested).is_success(), "unrelated generic policy keys retain compatibility")
nested[&"custom"][&"tags"].append(&"mutated")
_expect(facilities.get_instance(&"inn").policy_current[&"custom"][&"tags"] == [&"quiet"], "nested generic patches are copied")
_expect(facilities.set_policy(&"inn", {"price_mode": "affordable", "business_open": true}).is_success(), "string-key policy input is accepted")
_expect(facilities.get_instance(&"inn").policy_current[&"price_mode"] == &"affordable", "price IDs normalize consistently")
func _test_locked_quotes_and_scheduled_changes() -> void:
var facilities := _facilities()
var instance := facilities.get_instance(&"inn")
facilities.set_policy(&"inn", {&"price_mode": &"affordable"})
var queued := facilities.create_request(&"old", &"inn", [&"guest"], &"meal", 0)
_expect(queued.quoted_price == 20, "new request uses the facility's policy price")
_expect(facilities.enqueue_request(queued).is_success(), "quoted request enters queue")
var locked := queued.create_snapshot()
facilities.set_policy(&"inn", {&"price_mode": &"exorbitant"})
_expect(queued.create_snapshot() == locked, "queued price and policy remain locked across a price change")
var result := facilities.start_next_service(&"inn")
_expect(result.is_success(), "old quotation starts after a price change")
var active := facilities.get_service(StringName(result.details[&"service_id"]))
_expect(active.price_snapshot == 20 and active.policy_snapshot[&"price_mode"] == &"affordable", "active service uses its accepted price and policy")
var newest := facilities.create_request(&"new", &"inn", [&"other"], &"meal", 0)
_expect(newest.quoted_price == 63, "subsequent request sees the new price")
var explicit := facilities.create_request(&"explicit", &"inn", [&"another"], &"meal", 0, {&"quoted_price": 17})
_expect(explicit.quoted_price == 17, "an explicit quote is not multiplied a second time")
facilities.set_policy(&"inn", {&"price_mode": &"charitable"}, 5)
facilities.advance_one_minute(4)
_expect(instance.policy_current[&"price_mode"] == &"exorbitant", "scheduled price does not apply before its minute")
facilities.advance_one_minute(5)
_expect(instance.policy_current[&"price_mode"] == &"charitable", "scheduled price applies at its exact minute")
_expect(active.price_snapshot == 20 and active.policy_snapshot[&"price_mode"] == &"affordable", "completed service history preserves its accepted quotation")
_expect(instance.policy_pending.is_empty() and instance.policy_pending_revision == 0, "applied schedule clears its pending metadata")
func _test_closed_service_and_opening_hours() -> void:
var facilities := _facilities()
var instance := facilities.get_instance(&"inn")
var running := facilities.create_request(&"running", &"inn", [&"guest"], &"meal", 0)
facilities.enqueue_request(running)
var started := facilities.start_next_service(&"inn")
var active := facilities.get_service(StringName(started.details[&"service_id"]))
var waiting := facilities.create_request(&"waiting", &"inn", [&"waiter"], &"meal", 0)
facilities.enqueue_request(waiting)
_expect(facilities.set_policy(&"inn", {&"business_open": false}).is_success(), "operator can close a facility through policy")
_expect(not instance.operator_open and instance.state == Facility.State.CLOSED, "closure synchronizes operator flag and state immediately")
for minute: int in range(1, 5):
facilities.advance_one_minute(minute)
_expect(active.state == Service.State.COMPLETED, "an accepted service completes while closed")
_expect(facilities.get_queue(&"inn").size() == 1 and facilities.get_active_services(&"inn").is_empty(), "closure prevents new service starts")
facilities.set_policy(&"inn", {&"business_open": true}, 6)
facilities.advance_one_minute(5)
_expect(not instance.operator_open, "scheduled reopening does not occur early")
facilities.advance_one_minute(6)
_expect(instance.operator_open and facilities.get_active_services(&"inn").size() == 1, "scheduled reopening allows the next service")
facilities.set_opening_hours(&"inn", 600, 1_200, 6)
_expect(instance.state == Facility.State.CLOSED and instance.operator_open, "opening hours remain independent from operator intent")
facilities.set_policy(&"inn", {&"price_mode": &"expensive"})
_expect(instance.state == Facility.State.CLOSED, "price changes do not bypass opening hours")
facilities.advance_one_minute(600)
_expect(instance.state == Facility.State.OPEN, "scheduled opening retains business intent")
facilities.set_operational_status(&"inn", {&"operator_open": false}, 600)
_expect(not instance.operator_open and not instance.policy_current[&"business_open"], "generic operational closure stays synchronized with policy")
facilities.set_operational_status(&"inn", {&"operator_open": true}, 600)
_expect(instance.operator_open and instance.policy_current[&"business_open"], "generic operational reopening remains supported")
func _test_snapshot_restore_and_legacy_policy() -> void:
var facilities := _facilities()
facilities.set_policy(&"inn", {&"price_mode": &"expensive", &"business_open": true})
var accepted := facilities.create_request(&"accepted", &"inn", [&"guest"], &"meal", 0)
facilities.enqueue_request(accepted)
facilities.start_next_service(&"inn")
var waiting := facilities.create_request(&"waiting", &"inn", [&"waiter"], &"meal", 0)
facilities.enqueue_request(waiting)
facilities.set_policy(&"inn", {&"price_mode": &"charitable", &"business_open": false}, 8)
var snapshot := facilities.create_snapshot()
var restored := Facilities.new()
_expect(restored.restore_snapshot(snapshot), "current, pending, queued and active pricing survives restore")
_expect(restored.create_snapshot() == snapshot, "valid save restores byte-equivalent policies and quotations")
for minute: int in range(1, 9):
facilities.advance_one_minute(minute)
restored.advance_one_minute(minute)
_expect(restored.create_snapshot() == facilities.create_snapshot(), "restored pending policy replays deterministically")
var bad := snapshot.duplicate(true)
bad[&"instances"][0][&"policy_current"][&"price_mode"] = &"invalid"
var unchanged := restored.create_snapshot()
_expect(not restored.restore_snapshot(bad), "invalid saved price mode is rejected")
_expect(restored.create_snapshot() == unchanged, "bad price restore cannot change live state")
bad = snapshot.duplicate(true)
bad[&"instances"][0][&"policy_pending"][&"business_open"] = 0
_expect(not restored.restore_snapshot(bad), "invalid saved pending business state is rejected")
bad = snapshot.duplicate(true)
bad[&"requests"][0][&"policy_snapshot"][&"price_mode"] = &"invalid"
_expect(not restored.restore_snapshot(bad), "invalid saved request policy is rejected")
bad = snapshot.duplicate(true)
bad[&"instances"][0][&"operator_open"] = false
_expect(not restored.restore_snapshot(bad), "contradictory saved operator and business flags are rejected")
var legacy := _facilities()
legacy.set_operational_status(&"inn", {&"operator_open": false})
var legacy_snapshot := legacy.create_snapshot()
_expect(restored.restore_snapshot(legacy_snapshot), "legacy operator-only saves remain supported")
_expect(restored.create_snapshot() == legacy_snapshot and not restored.get_instance(&"inn").operator_open, "legacy closure restores without inserting policy keys")
var configured := Facility.new().configure(&"configured", &"cafeteria", {&"policy_current": {&"business_open": false}})
_expect(not configured.operator_open and configured.state == Facility.State.CLOSED, "configured business policy initializes operator state")
func _test_queue_release_is_atomic() -> void:
var guests := Guests.new()
guests.create_definition(&"visitor", {&"base_patience": 30})
var guest := guests.spawn_guest(&"waiting", &"visitor", 0, {&"state": Guest.State.DECIDING})
guests.enter_queue(&"waiting", &"inn", &"request", 0, 17)
guest.path_handle = {&"path": [Vector3i.ZERO]}
var callback_state := {&"calls": 0, &"allow": false, &"received": []}
var callback := func(guest_id: StringName, facility_id: StringName, request_id: StringName) -> bool:
callback_state[&"calls"] += 1
callback_state[&"received"] = [guest_id, facility_id, request_id]
return bool(callback_state[&"allow"])
var before := guest.create_snapshot()
_expect(not bool(guests.release_queue_for_replanning(&"waiting", 1, callback)[&"ok"]), "failed external queue removal rejects redecision")
_expect(guest.create_snapshot() == before, "failed external queue removal preserves guest state and patience")
callback_state[&"allow"] = true
guest.transition_tick = 1
guest.transitions_this_tick = Guests.MAX_TRANSITIONS_PER_TICK
before = guest.create_snapshot()
_expect(not bool(guests.release_queue_for_replanning(&"waiting", 1, callback)[&"ok"]), "full transition budget defers queue release")
_expect(callback_state[&"calls"] == 1 and guest.create_snapshot() == before, "transition preflight protects external ownership and guest state")
var reasons: Array[StringName] = []
guests.guest_state_changed.connect(func(_id: StringName, _old: int, _new: int, reason: StringName): reasons.append(reason))
_expect(bool(guests.release_queue_for_replanning(&"waiting", 2, callback)[&"ok"]), "queue release retries on the next available minute")
_expect(callback_state[&"received"] == [&"waiting", &"inn", &"request"], "external release receives the exact ownership tuple")
_expect(guest.state == Guest.State.DECIDING and guest.patience_remaining == 17, "closure causes redecision without a patience penalty")
_expect(guest.target_facility_id.is_empty() and guest.queue_request_id.is_empty() and guest.path_handle == null, "successful release clears obsolete target, request and path")
_expect(reasons == [&"FACILITY_CLOSED"], "closure publishes a distinct redecision reason")
_expect(not bool(guests.release_queue_for_replanning(&"waiting", 3, callback)[&"ok"]) and callback_state[&"calls"] == 2, "released guest cannot release the request twice")
_expect(not bool(guests.release_queue_for_replanning(&"missing", 3)[&"ok"]), "missing guests cannot enter the release path")
func _facilities() -> FacilityService:
var facilities := Facilities.new()
facilities.register_instance(Facility.new().configure(&"inn", &"cafeteria", {
&"capacity": 1, &"queue_capacity": 8, &"base_price": 25,
&"base_quality": 50, &"base_service_minutes": 4,
}))
return facilities
func _expect(condition: bool, message: String) -> void:
_checks += 1
if condition:
print(" ✓ %s" % message)
else:
_failures += 1
push_error(" ✗ %s" % message)
+1
View File
@@ -0,0 +1 @@
uid://d37y3hbvoei5k
+1 -1
View File
@@ -88,7 +88,7 @@ func _test_policy_revision_and_request_snapshot() -> void:
{&"quoted_price": 120}
)
_expect(service.enqueue_request(request, 100).is_success(), "policy-quoted request enters queue")
service.set_policy(facility.instance_id, {&"price_mode": &"premium"})
service.set_policy(facility.instance_id, {&"price_mode": &"expensive"})
_expect(request.policy_snapshot[&"price_mode"] == &"standard", "queued request retains original policy snapshot")
_expect(request.policy_revision == 1 and facility.policy_revision == 2, "request retains original policy revision")
var scheduled = service.set_policy(facility.instance_id, {&"risk_mode": true}, 120)
+4 -4
View File
@@ -170,7 +170,7 @@ func _run_service_sale_with_round_trip() -> void:
var active: Array = _runtime.facility_service.get_active_services(_facility_id)
_expect(active.size() == 1, "facility queue fills an available service slot")
var service_id: StringName = active[0].service_id
_expect(active[0].price_snapshot == 35 and active[0].duration_minutes == 3, "service freezes quoted price and duration")
_expect(active[0].price_snapshot == 25 and active[0].duration_minutes == 3, "service freezes quoted price and duration")
var mid_snapshot: Dictionary = _runtime.create_snapshot()
var mid_digest := SimulationDigest.compute(mid_snapshot)
@@ -178,9 +178,9 @@ func _run_service_sale_with_round_trip() -> void:
var completed: ServiceInstance = _runtime.facility_service.get_service(service_id)
_expect(completed != null and completed.state == ServiceInstance.State.COMPLETED, "three active minutes complete the service")
_expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 1, "completion consumes exactly one reserved ingredient")
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == 65, "guest pays the immutable quoted price")
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4235, "service cash sweep increases player Gold")
_expect(_state.get_resource(&"gold") == 4235, "UI Gold mirror follows the ledger transaction")
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == 75, "guest pays the immutable quoted price")
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4225, "service cash sweep increases player Gold")
_expect(_state.get_resource(&"gold") == 4225, "UI Gold mirror follows the ledger transaction")
_expect(_runtime.economy_service.validate_ledger()[&"ok"], "service sale remains double-entry balanced")
var saw_completion := false
for event in _runtime.domain_events.recent_events():
+2 -2
View File
@@ -202,8 +202,8 @@ func _run_autonomous_guest_service_with_replay() -> void:
_expect(completed != null and completed.state == ServiceInstance.State.COMPLETED, "service completes after its immutable duration")
_expect(guest != null and guest.satisfaction_samples == 1, "completion records exactly one Guest satisfaction sample")
_expect(guest != null and int(guest.needs[&"hunger"]) < 40, "cafeteria completion applies its Need relief")
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_mio") == 115, "Guest pays the quoted service price once")
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4235, "service revenue reaches authoritative player cash")
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_mio") == 125, "Guest pays the quoted service price once")
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4225, "service revenue reaches authoritative player cash")
_expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 0, "service consumes exactly one reserved recipe input")
var final_digest: String = _runtime.latest_digest
+15 -5
View File
@@ -79,12 +79,15 @@ func _run() -> void:
var summaries: Array = _runtime.operations_coordinator.get_day_summaries()
_expect(summaries.size() == day and bool(summaries[-1].get(&"balanced", false)), "business day %d closes with a reconciled ledger" % day)
_expect(_state.get_resource(&"gold") >= 0, "day %d retains cash after real payroll and purchases" % day)
_expect(int(summaries[-1].get(&"arrears_total", -1)) == 0, "day %d pays all real wages without arrears" % day)
if day < 3:
_command(&"purchase_supplies", {&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40})
_expect(_sales(&"standard_room") > 0, "the same inn earns lodging revenue")
_expect(_sales(&"reception") == 0, "three days do not depend on artificial reception revenue")
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "three-day supply history remains restorable")
var evidence := {"checks": _checks, "failures": _failures, "meal_sales": _sales(&"cafeteria"), "lodging_sales": _sales(&"standard_room"), "raw_deliveries": _completed_deliveries(SupplyCatalog.RAW_FOOD), "meal_deliveries": _completed_deliveries(SupplyCatalog.BASIC_MEAL), "cash": _state.get_resource(&"gold"), "closes": _runtime.operations_coordinator.get_day_summaries(), "supply": _runtime.get_supply_management_report()}
var evidence_path := ProjectSettings.globalize_path("res://artifacts/supply-chain/normal-play-report.json")
var evidence_dir := "res://artifacts/business-management" if OS.get_environment("THE_TREE_BUSINESS_ACCEPTANCE") == "1" else "res://artifacts/supply-chain"
var evidence_path := ProjectSettings.globalize_path(evidence_dir.path_join("normal-play-report.json"))
DirAccess.make_dir_recursive_absolute(evidence_path.get_base_dir())
var file := FileAccess.open(evidence_path, FileAccess.WRITE)
if file != null:
@@ -194,9 +197,16 @@ func _start_preview() -> void:
if callback.get_method() in [&"_save_manual", &"_load_manual"]:
button.disabled = true
button.tooltip_text = "この試遊は保存しません。"
_main.call("_activate_management_mode", &"product")
var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel
panel.select_tab(&"kitchen")
_main.call("_set_status", "通常予算から育てた宿の試遊です。Spaceで再開し、商品タブで仕入れ・調理・配送を確認できます(保存なし)。", true, true)
if OS.get_environment("THE_TREE_BUSINESS_PREVIEW") == "1":
root.title = "The Tree · 料金と営業の試遊(保存なし)"
_main.call("_activate_management_mode", &"business")
var business := _main.find_child("BusinessManagementPanel", true, false)
business.call("select_facility", _ids[&"cafeteria"])
_main.call("_set_status", "料金と営業状態を変更できます。Spaceで再開すると、客の行動に反映されます(保存なし)。", true, true)
else:
_main.call("_activate_management_mode", &"product")
var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel
panel.select_tab(&"kitchen")
_main.call("_set_status", "通常予算から育てた宿の試遊です。Spaceで再開し、商品タブで仕入れ・調理・配送を確認できます(保存なし)。", true, true)
_clock.set_paused(true)
print("SUPPLY_PREVIEW_READY checks=%d failures=0" % _checks)
@@ -75,6 +75,8 @@ func _run() -> void:
_test_unavailable_and_expiry()
_test_restore_guards()
_test_pickup_retry()
_test_source_closes_after_assignment()
_test_source_closes_after_loading()
_test_unavailable_before_pickup()
_test_partial_expiry_return()
_test_shift_boundary()
@@ -202,6 +204,36 @@ func _test_pickup_retry() -> void:
_expect(_quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "retry delivers the same stock exactly once")
func _test_source_closes_after_assignment() -> void:
_expect(_runtime.restore_snapshot(_reserved), "restore source-closure approach scenario")
_clock.advance_minutes(1)
var old_task := _worker().current_task_id
_expect(_request().state == LogisticsRequest.State.ASSIGNED and not old_task.is_empty(), "source closure begins after assigning the real pickup Task")
_expect(_command(&"set_facility_policy", {&"facility_id": _source, &"patch": {&"business_open": false}}), "close the source while its porter approaches")
_wait_for(LogisticsRequest.State.BLOCKED, 40)
_expect(_request().state == LogisticsRequest.State.BLOCKED and _request().state_reason == "SOURCE_CLOSED", "arrival at a newly closed source blocks before loading")
_expect(_quantity(_inventory(_source)) == 8 and _quantity(_cargo()) == 0 and _quantity(_inventory(_destination)) == 0, "closed source retains every purchased unit without transfer")
_expect(_runtime.inventory_service.available_quantity(_inventory(_source), SupplyCatalog.RAW_FOOD) == 8 and _worker().current_task_id.is_empty(), "blocked pickup releases both source stock and employee reservations")
_expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "closed-source cleanup retains no dispatcher or Task ghost")
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "blocked closed-source state passes strict Runtime restore")
_expect(_command(&"set_facility_policy", {&"facility_id": _source, &"patch": {&"business_open": true}}), "reopen the source for a retry")
_wait_for(LogisticsRequest.State.DELIVERING, 40)
_expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_task_id != old_task and _quantity(_cargo()) == 8, "reopening retries with a fresh Task and physical pickup")
var loaded_cell := _worker().current_cell
_wait_for(LogisticsRequest.State.COMPLETED, 40)
_expect(_request().state == LogisticsRequest.State.COMPLETED and _worker().current_cell != loaded_cell and _quantity(_inventory(_destination)) == 8, "reopened source stock reaches the kitchen by actual porter movement")
_expect(_quantity(_inventory(_source)) == 0 and _quantity(_cargo()) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "reopened source delivery consumes and releases ownership exactly once")
func _test_source_closes_after_loading() -> void:
_expect(_runtime.restore_snapshot(_loaded), "restore loaded source-closure scenario")
_expect(_command(&"set_facility_policy", {&"facility_id": _source, &"patch": {&"business_open": false}}), "close the source after loading")
var cell := _worker().current_cell
_wait_for(LogisticsRequest.State.COMPLETED, 40)
_expect(_request().state == LogisticsRequest.State.COMPLETED and _worker().current_cell != cell and _quantity(_inventory(_destination)) == 8, "already-loaded porter can finish delivery to an open destination")
_expect(_quantity(_cargo()) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "delivery after source closure clears cargo and carrier ownership")
func _test_unavailable_before_pickup() -> void:
_expect(_runtime.restore_snapshot(_reserved), "restore unloaded health failure")
_clock.advance_minutes(1)
+259
View File
@@ -0,0 +1,259 @@
extends SceneTree
## Real Main controls, paid builds and the Runtime command queue. Dedicated disk
## saves verify that a pending edit cannot be charged/applied twice after Load.
var _checks := 0
var _failures := 0
var _main: Control
var _panel: BusinessManagementPanel
var _runtime: Variant
var _clock: Variant
var _rooms: Array[StringName] = []
var _reception: StringName = &""
var _warehouse: StringName = &""
var _save_path := "user://tests/business_ui_%d/queued_policy" % OS.get_process_id()
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_clock = root.get_node("GameClock")
_runtime = root.get_node("SimulationRuntime")
var state: Variant = root.get_node("GameState")
_clock.reset(1, 6, 0)
state.reset()
_runtime.initialize(_clock, state, 824260)
_clock.set_paused(true)
root.size = Vector2i(1280, 720)
_main = load("res://scenes/main/main.tscn").instantiate() as Control
_main.set("manual_save_path", "user://tests/business_ui_%d/manual" % OS.get_process_id())
root.add_child(_main)
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
await process_frame
await process_frame
_panel = _main.find_child("BusinessManagementPanel", true, false) as BusinessManagementPanel
_expect(_panel != null, "normal Main includes business management")
if _panel == null:
_finish()
return
(_main.get("navigation_buttons")[&"business"] as Button).pressed.emit()
_expect(_panel.visible and _panel.apply_button.disabled and _panel.facility_option.disabled,
"business nav opens a safe empty state before completed construction")
_expect("完成" in _panel.facility_status_label.text, "empty state explains completed facility prerequisite")
for request: Array in [
[&"reception", Vector3i(-8, 0, 0)],
[&"standard_room", Vector3i(-8, 2, 0)],
[&"standard_room", Vector3i(-6, 2, 0)],
[&"warehouse", Vector3i(-8, 0, 1)],
]:
var before: int = _runtime.construction_service.get_jobs().size()
_main.call("_select_build_definition", request[0])
_main.call("_on_map_cell_clicked", request[1])
_expect(_runtime.construction_service.get_jobs().size() == before + 1, "normal UI pays for construction of %s" % request[0])
if request[0] == &"reception":
_clock.advance_minutes(120)
_clock.advance_minutes(180)
for facility: FacilityInstance in _runtime.facility_service.get_instances():
if facility.definition_id == &"standard_room":
_rooms.append(facility.instance_id)
elif facility.definition_id == &"reception":
_reception = facility.instance_id
elif facility.definition_id == &"warehouse":
_warehouse = facility.instance_id
_expect(_rooms.size() == 2 and not _reception.is_empty() and not _warehouse.is_empty(), "paid builds complete two rooms, free reception and warehouse")
if _rooms.size() != 2 or _reception.is_empty() or _warehouse.is_empty():
_finish()
return
_main.call("_activate_management_mode", &"business")
_panel.refresh()
_test_prices_and_drafts()
_test_open_close()
_test_saved_pending_edit()
await _test_navigation_and_layout()
_finish()
func _test_prices_and_drafts() -> void:
_choose(_panel.facility_option, _rooms[0])
_expect(_panel.price_option.item_count == 5 and not _panel.price_option.disabled, "five price modes are available for paid guest rooms")
var row := _row(_rooms[0])
_expect(int(row.get(&"base_price", 0)) == 120 and "120G" in _panel.quote_label.text,
"initial room shows actual 120 G base and current quotation")
var digest: String = _runtime.latest_digest
var cash: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
for pair: Array in [[&"charitable", 72], [&"affordable", 96], [&"standard", 120], [&"expensive", 180], [&"exorbitant", 300]]:
_choose(_panel.price_option, pair[0])
_expect("設定後 %dG" % pair[1] in _panel.quote_label.text, "%s mode previews authoritative rounded price" % pair[0])
_expect(_runtime.latest_digest == digest and _runtime.economy_service.get_balance(&"PLAYER_CASH") == cash,
"changing drafts and displaying quotes never mutates Runtime or spends cash")
_expect("満足度" in _panel.effect_label.text and "支払えない" in _panel.effect_label.text,
"highest price explains satisfaction and affordability instead of an invented demand forecast")
_choose(_panel.price_option, &"affordable")
_main.call("_activate_management_mode", &"product")
_clock.advance_minutes(1)
_main.call("_activate_management_mode", &"business")
_expect(_selected_mode() == &"affordable" and not _panel.apply_button.disabled,
"unsaved price draft survives time updates and other management panels")
_choose(_panel.facility_option, _rooms[1])
_choose(_panel.price_option, &"expensive")
_choose(_panel.facility_option, _rooms[0])
_expect(_selected_mode() == &"affordable", "separate facilities keep separate price drafts")
_clock.set_paused(false)
_panel.apply_button.pressed.emit()
_expect(_panel.apply_button.disabled and _panel.price_option.disabled and _panel.business_open_button.disabled,
"queued policy disables edits and duplicate submissions for that facility")
_choose(_panel.facility_option, _rooms[1])
_clock.advance_minutes(1)
_clock.set_paused(true)
_expect(_selected_mode() == &"expensive" and _panel.selected_facility_id == _rooms[1],
"completion on one facility preserves the selected other facility draft")
_expect(StringName(_row(_rooms[0]).get(&"price_mode", &"")) == &"affordable" and int(_row(_rooms[0]).get(&"price", 0)) == 96,
"accepted price reaches facility policy and Runtime report")
_panel.apply_button.pressed.emit()
_expect(int(_row(_rooms[1]).get(&"price", 0)) == 180 and "反映" in _panel.result_label.text,
"paused submission applies another facility's own draft with Japanese confirmation")
_expect(_panel.apply_button.disabled, "applied unchanged draft cannot be submitted again")
func _test_open_close() -> void:
_panel.business_open_button.button_pressed = false
_expect(not _panel.apply_button.disabled, "closing admission is an explicit unsaved draft")
_panel.apply_button.pressed.emit()
_expect(not bool(_row(_rooms[1]).get(&"business_open", true)) and "停止" in _panel.facility_status_label.text,
"close reaches actual facility operator state and status text")
_expect("受付済みの料金は変わりません" in _panel.explanation_label.text
and "開始済みのサービス" in _panel.explanation_label.text and "給与は発生" in _panel.explanation_label.text,
"UI explains fixed accepted prices, service completion, and continued wages during closure")
_panel.business_open_button.button_pressed = true
_panel.apply_button.pressed.emit()
_expect(bool(_row(_rooms[1]).get(&"business_open", false)), "admission can be reopened through the same controls")
_choose(_panel.facility_option, _reception)
_expect(_panel.price_option.disabled and "利用料金はありません" in _panel.quote_label.text,
"free reception has no editable price")
_panel.business_open_button.button_pressed = false
_panel.apply_button.pressed.emit()
_expect(not bool(_row(_reception).get(&"business_open", true)), "free facility still supports admission control")
_panel.business_open_button.button_pressed = true
_panel.apply_button.pressed.emit()
_expect(bool(_row(_reception).get(&"business_open", false)), "free reception can reopen without sending a price patch")
_choose(_panel.facility_option, _warehouse)
_expect(_panel.price_option.disabled and _panel.business_open_button.text == "営業する"
and "調理と新しい補給" in _panel.explanation_label.text and "荷物は保持" in _panel.explanation_label.text,
"backroom warehouse explains supply pause and retained cargo instead of guest admission")
_panel.business_open_button.button_pressed = false
_panel.apply_button.pressed.emit()
_expect(not bool(_row(_warehouse).get(&"business_open", true)), "warehouse closing updates the real operator policy")
_panel.business_open_button.button_pressed = true
_panel.apply_button.pressed.emit()
_expect(bool(_row(_warehouse).get(&"business_open", false)), "warehouse can reopen through the same business panel")
func _test_saved_pending_edit() -> void:
_choose(_panel.facility_option, _rooms[0])
_choose(_panel.price_option, &"charitable")
_clock.set_paused(false)
_panel.apply_button.pressed.emit()
var pending_before: int = _runtime.command_queue.create_snapshot().get("pending", []).size()
_panel.submit_policy()
_expect(pending_before == 1 and _runtime.command_queue.create_snapshot().get("pending", []).size() == 1,
"a second press cannot enqueue duplicate policy edits")
var saved: Dictionary = _runtime.save_to_path(_save_path)
_expect(bool(saved.get(&"ok", false)), "pending price policy saves to an isolated disk slot")
_clock.advance_minutes(1)
var loaded: Dictionary = _runtime.load_from_path(_save_path)
_expect(bool(loaded.get(&"ok", false)), "pending policy restores from disk")
_panel.refresh()
_expect(_panel.apply_button.disabled and _selected_mode() == &"charitable" and "設定後 72G" in _panel.quote_label.text,
"restored pending edit remains locked and displays its accepted draft, not stale live policy")
_clock.advance_minutes(1)
_clock.set_paused(true)
_panel.refresh()
_expect(int(_row(_rooms[0]).get(&"price", 0)) == 72 and not _panel.price_option.disabled,
"restored policy executes once and unlocks editing")
_choose(_panel.price_option, &"standard")
_panel.apply_button.pressed.emit()
_expect(int(_row(_rooms[0]).get(&"price", 0)) == 120,
"restored command key sequence permits a genuinely new policy edit")
var report := _panel.get_report()
(report[&"facilities"] as Array).clear()
_panel.refresh()
_expect(not (_panel.get_report().get(&"facilities", []) as Array).is_empty(), "read model returned to callers is defensive")
func _test_navigation_and_layout() -> void:
for entry: Array in [[&"ledger", "operating_ledger_panel"], [&"product", "supply_management_panel"], [&"staff", "staff_management_panel"]]:
_main.call("_activate_management_mode", entry[0])
_expect(not _panel.visible and (_main.get(entry[1]) as Control).visible, "%s exclusively replaces business controls" % entry[0])
_main.call("_activate_management_mode", &"business")
_expect(_panel.visible and not (_main.get(entry[1]) as Control).visible, "business exclusively replaces %s controls" % entry[0])
_main.call("_unhandled_key_input", _key(KEY_H))
_expect(not _panel.visible and (_main.get("staff_candidate_panel") as Control).visible, "H replaces business with hiring candidates")
_main.call("_activate_management_mode", &"business")
_choose(_panel.facility_option, _rooms[1])
_main.call("_set_layer", 0)
await process_frame
await process_frame
var side := _main.find_child("SidePanel", true, false) as Control
var guide := _main.find_child("InnOpeningGuide", true, false) as Control
_expect(not side.get_global_rect().intersects(guide.get_global_rect()), "1280x720 business inspector stays clear of opening guidance")
for control: Control in [_panel, _panel.facility_option, _panel.price_option, _panel.business_open_button, _panel.apply_button]:
_expect(control.get_global_rect().position.x >= side.get_global_rect().position.x
and control.get_global_rect().end.x <= side.get_global_rect().end.x, "%s fits inside narrow sidebar" % control.name)
if OS.get_environment("THE_TREE_BUSINESS_UI_CAPTURE") == "1":
var capture_dir := ProjectSettings.globalize_path("res://artifacts/business-management")
DirAccess.make_dir_recursive_absolute(capture_dir)
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png(capture_dir.path_join("business-panel.png"))
_choose(_panel.facility_option, _warehouse)
_main.call("_set_layer", 1)
await process_frame
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png(capture_dir.path_join("warehouse-policy.png"))
_main.call("_unhandled_key_input", _key(KEY_ESCAPE))
_expect(not _panel.visible and not bool(_main.get("_management_panel_open")), "Escape closes business inspector and restores full map")
func _row(id: StringName) -> Dictionary:
for row: Dictionary in _runtime.get_business_management_report().get(&"facilities", []):
if StringName(row.get(&"facility_id", &"")) == id:
return row
return {}
func _selected_mode() -> StringName:
return StringName(_panel.price_option.get_item_metadata(_panel.price_option.selected))
func _choose(picker: OptionButton, id: StringName) -> void:
for index: int in picker.item_count:
if StringName(picker.get_item_metadata(index)) == id:
picker.select(index)
picker.item_selected.emit(index)
return
_expect(false, "requested business dropdown value exists: %s" % id)
func _key(code: Key) -> InputEventKey:
var event := InputEventKey.new()
event.keycode = code
event.pressed = true
return event
func _expect(condition: bool, message: String) -> void:
_checks += 1
if not condition:
_failures += 1
push_error(message)
func _finish() -> void:
SaveService.delete_save(_save_path)
if is_instance_valid(_main):
_main.queue_free()
print("Business management UI: %d checks, %d failures" % [_checks, _failures])
quit(1 if _failures > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://bqsf72kvrm7qn
+3 -3
View File
@@ -105,14 +105,14 @@ func _run() -> void:
)
var navigation := _main.find_child("NavigationRail", true, false) as Control
_expect(
navigation != null and (_main.get("navigation_buttons") as Dictionary).size() == 6,
"dashboard exposes the six Japanese management categories"
navigation != null and (_main.get("navigation_buttons") as Dictionary).size() == 7,
"dashboard exposes the seven Japanese management categories"
)
var iconized_navigation_buttons := 0
for navigation_button_value: Variant in (_main.get("navigation_buttons") as Dictionary).values():
if (navigation_button_value as Button).icon != null:
iconized_navigation_buttons += 1
_expect(iconized_navigation_buttons == 6, "all management categories consume theme-provided icons")
_expect(iconized_navigation_buttons == 7, "all management categories consume theme-provided icons")
var resource_icon_nodes := _main.get("resource_icon_nodes") as Dictionary
_expect(
resource_icon_nodes.size() == 4