Add business pricing and closure controls with compatible digest optimization
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = &""
|
||||
|
||||
@@ -11,6 +11,19 @@ extends RefCounted
|
||||
const FORMAT_VERSION := 1
|
||||
const MAX_NESTING_DEPTH := 128
|
||||
|
||||
# A single digest owns these caches; nothing survives a call or retains snapshot
|
||||
# values. Only private arrays of immutable, homogeneous keys become cache keys.
|
||||
# Separate buckets preserve the distinct String / StringName canonical tags.
|
||||
const MAX_CACHED_LAYOUTS := 256
|
||||
const MAX_CACHED_LAYOUT_KEYS := 32
|
||||
const MAX_CACHED_KEY_LENGTH := 128
|
||||
|
||||
class KeyLayouts:
|
||||
extends RefCounted
|
||||
var strings: Dictionary = {}
|
||||
var names: Dictionary = {}
|
||||
|
||||
|
||||
|
||||
static func compute(value: Variant) -> String:
|
||||
var canonical := canonical_bytes(value)
|
||||
@@ -27,12 +40,12 @@ static func compute(value: Variant) -> String:
|
||||
static func canonical_bytes(value: Variant) -> PackedByteArray:
|
||||
var output := PackedByteArray()
|
||||
_append_ascii(output, "the-tree:simulation-digest:v%d;" % FORMAT_VERSION)
|
||||
if not _encode_value(value, output, 0):
|
||||
if not _encode_value(value, output, 0, KeyLayouts.new()):
|
||||
return PackedByteArray()
|
||||
return output
|
||||
|
||||
|
||||
static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -> bool:
|
||||
static func _encode_value(value: Variant, output: PackedByteArray, depth: int, layouts: KeyLayouts) -> bool:
|
||||
if depth > MAX_NESTING_DEPTH:
|
||||
push_error("SimulationDigest data exceeds the maximum nesting depth")
|
||||
return false
|
||||
@@ -107,9 +120,9 @@ static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -
|
||||
and _append_float64(output, color.a)
|
||||
)
|
||||
TYPE_ARRAY:
|
||||
return _encode_array(value, output, depth)
|
||||
return _encode_array(value, output, depth, layouts)
|
||||
TYPE_DICTIONARY:
|
||||
return _encode_dictionary(value, output, depth)
|
||||
return _encode_dictionary(value, output, depth, layouts)
|
||||
TYPE_PACKED_BYTE_ARRAY:
|
||||
_append_sized_bytes(output, "pb", value)
|
||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
||||
@@ -130,34 +143,73 @@ static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_array(value: Array, output: PackedByteArray, depth: int) -> bool:
|
||||
static func _encode_array(value: Array, output: PackedByteArray, depth: int, layouts: KeyLayouts) -> bool:
|
||||
_append_ascii(output, "a%d:" % value.size())
|
||||
# Event IDs and several saved histories are typed string arrays. Their element
|
||||
# type is guaranteed by Godot, so avoid the recursive Variant dispatch for each
|
||||
# entry while keeping the same depth limit and exact bytes.
|
||||
if value.is_typed() and value.get_typed_builtin() in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
if not value.is_empty() and depth >= MAX_NESTING_DEPTH:
|
||||
push_error("SimulationDigest data exceeds the maximum nesting depth")
|
||||
return false
|
||||
var tag := "s" if value.get_typed_builtin() == TYPE_STRING else "q"
|
||||
for item in value:
|
||||
_append_sized_bytes(output, tag, String(item).to_utf8_buffer())
|
||||
return true
|
||||
for item in value:
|
||||
if not _encode_value(item, output, depth + 1):
|
||||
if not _encode_value(item, output, depth + 1, layouts):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_dictionary(value: Dictionary, output: PackedByteArray, depth: int) -> bool:
|
||||
var entries: Array[Dictionary] = []
|
||||
for key in value:
|
||||
var encoded_key := PackedByteArray()
|
||||
if not _encode_value(key, encoded_key, depth + 1):
|
||||
return false
|
||||
entries.append({
|
||||
"sort_key": encoded_key.hex_encode(),
|
||||
"encoded_key": encoded_key,
|
||||
"value": value[key],
|
||||
})
|
||||
entries.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
|
||||
return String(left["sort_key"]) < String(right["sort_key"])
|
||||
)
|
||||
static func _encode_dictionary(value: Dictionary, output: PackedByteArray, depth: int, layouts: KeyLayouts) -> bool:
|
||||
if not value.is_empty() and depth >= MAX_NESTING_DEPTH:
|
||||
push_error("SimulationDigest data exceeds the maximum nesting depth")
|
||||
return false
|
||||
var keys := value.keys()
|
||||
var cached_layouts := _layout_bucket(keys, layouts)
|
||||
var entries: Array = []
|
||||
if not cached_layouts.is_empty() and cached_layouts.has(keys):
|
||||
entries = cached_layouts[keys]
|
||||
else:
|
||||
for key in keys:
|
||||
var encoded_key := PackedByteArray()
|
||||
if not _encode_value(key, encoded_key, depth + 1, layouts):
|
||||
return false
|
||||
entries.append([key, encoded_key, encoded_key.hex_encode()])
|
||||
entries.sort_custom(func(left: Array, right: Array) -> bool:
|
||||
return String(left[2]) < String(right[2])
|
||||
)
|
||||
if _can_cache_keys(keys) and cached_layouts.size() < MAX_CACHED_LAYOUTS:
|
||||
# keys is a fresh private array from Dictionary.keys(). It and entries
|
||||
# are never mutated once stored. Native Dictionary lookup compares full
|
||||
# array contents after hashing, so hash collisions cannot alias layouts.
|
||||
cached_layouts[keys] = entries
|
||||
|
||||
_append_ascii(output, "d%d:" % entries.size())
|
||||
for entry in entries:
|
||||
var encoded_key: PackedByteArray = entry["encoded_key"]
|
||||
output.append_array(encoded_key)
|
||||
if not _encode_value(entry["value"], output, depth + 1):
|
||||
for entry: Array in entries:
|
||||
output.append_array(entry[1])
|
||||
# Read each value live. A reused key layout never caches child containers
|
||||
# or their bytes, including when several dictionaries share the schema.
|
||||
if not _encode_value(value[entry[0]], output, depth + 1, layouts):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _layout_bucket(keys: Array, layouts: KeyLayouts) -> Dictionary:
|
||||
if _can_cache_keys(keys):
|
||||
return layouts.strings if typeof(keys[0]) == TYPE_STRING else layouts.names
|
||||
return {}
|
||||
|
||||
|
||||
static func _can_cache_keys(keys: Array) -> bool:
|
||||
if keys.is_empty() or keys.size() > MAX_CACHED_LAYOUT_KEYS:
|
||||
return false
|
||||
var key_type := typeof(keys[0])
|
||||
if key_type != TYPE_STRING and key_type != TYPE_STRING_NAME:
|
||||
return false
|
||||
for key in keys:
|
||||
if typeof(key) != key_type or String(key).length() > MAX_CACHED_KEY_LENGTH:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ class_name FacilityInstance
|
||||
extends RefCounted
|
||||
|
||||
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
|
||||
const FacilityPricingType = preload("res://scripts/facility/facility_pricing.gd")
|
||||
|
||||
## Values are deliberately ordered by operational priority.
|
||||
enum State {
|
||||
@@ -81,6 +82,8 @@ func configure(
|
||||
access_available = bool(_value(options, &"access_available", true))
|
||||
power_available = bool(_value(options, &"power_available", true))
|
||||
operator_open = bool(_value(options, &"operator_open", true))
|
||||
if _has_value(policy_current, &"business_open"):
|
||||
operator_open = bool(_value(policy_current, &"business_open", operator_open))
|
||||
var intervals := _parse_intervals(_value(options, &"opening_intervals", []))
|
||||
if not intervals.is_empty():
|
||||
opening_intervals = intervals
|
||||
@@ -98,7 +101,8 @@ func is_valid() -> bool:
|
||||
and base_service_minutes > 0 \
|
||||
and staff_min >= 0 \
|
||||
and staff_max >= staff_min \
|
||||
and _intervals_are_valid(opening_intervals)
|
||||
and _intervals_are_valid(opening_intervals) \
|
||||
and bool(FacilityPricingType.validate_patch(policy_current)[&"valid"])
|
||||
|
||||
|
||||
func set_opening_hours(open_minute_of_day: int, close_minute_of_day: int) -> bool:
|
||||
@@ -144,6 +148,8 @@ func set_operational_conditions(conditions: Dictionary, absolute_minute: int) ->
|
||||
power_available = bool(_value(conditions, &"power_available", power_available))
|
||||
if _has_value(conditions, &"operator_open"):
|
||||
operator_open = bool(_value(conditions, &"operator_open", operator_open))
|
||||
if _has_value(policy_current, &"business_open"):
|
||||
apply_policy_patch({&"business_open": operator_open})
|
||||
if _has_value(conditions, &"active_staff_ids"):
|
||||
active_staff_ids = _sorted_unique_names(_value(conditions, &"active_staff_ids", []))
|
||||
if _has_value(conditions, &"degraded_reasons"):
|
||||
@@ -218,6 +224,8 @@ func apply_policy_patch(patch: Dictionary) -> bool:
|
||||
policy_current.erase(String(key))
|
||||
policy_current[key] = _copy(value)
|
||||
changed = true
|
||||
if _has_value(patch, &"business_open"):
|
||||
operator_open = bool(_value(patch, &"business_open", operator_open))
|
||||
if changed:
|
||||
policy_revision += 1
|
||||
return changed
|
||||
@@ -360,6 +368,13 @@ static func _snapshot_shapes_are_valid(snapshot: Dictionary) -> bool:
|
||||
for field: StringName in dictionary_fields:
|
||||
if not snapshot.get(field, null) is Dictionary:
|
||||
return false
|
||||
for field: StringName in [&"policy_current", &"policy_pending"]:
|
||||
if not bool(FacilityPricingType.validate_patch(snapshot[field])[&"valid"]):
|
||||
return false
|
||||
var saved_policy: Dictionary = snapshot[&"policy_current"]
|
||||
if _has_value(saved_policy, &"business_open") \
|
||||
and _value(saved_policy, &"business_open", true) != snapshot.get(&"operator_open", true):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
class_name FacilityPricing
|
||||
extends RefCounted
|
||||
|
||||
## Shared integer pricing rules for quotations, guest outcomes and management UI.
|
||||
## Unrelated policy keys remain the responsibility of their owning systems.
|
||||
|
||||
const DEFAULT_MODE := &"standard"
|
||||
const MODES: Array[Dictionary] = [
|
||||
{&"id": &"charitable", &"label": "奉仕価格", &"percent": 60, &"satisfaction_delta": 10},
|
||||
{&"id": &"affordable", &"label": "安価", &"percent": 80, &"satisfaction_delta": 5},
|
||||
{&"id": &"standard", &"label": "標準", &"percent": 100, &"satisfaction_delta": 0},
|
||||
{&"id": &"expensive", &"label": "高価", &"percent": 150, &"satisfaction_delta": -5},
|
||||
{&"id": &"exorbitant", &"label": "ぼったくり", &"percent": 250, &"satisfaction_delta": -20},
|
||||
]
|
||||
|
||||
|
||||
static func modes() -> Array[Dictionary]:
|
||||
return MODES.duplicate(true)
|
||||
|
||||
|
||||
static func quote(base_price: int, policy: Dictionary) -> int:
|
||||
var percent := int(_mode(policy)[&"percent"])
|
||||
# Currency is integer gold. An exact half gold rounds up, without depending
|
||||
# on floating-point rounding (35 G at 150 percent is 53 G).
|
||||
@warning_ignore("integer_division")
|
||||
var result := (maxi(base_price, 0) * percent + 50) / 100
|
||||
return result
|
||||
|
||||
|
||||
static func satisfaction_modifier(
|
||||
policy: Dictionary,
|
||||
quality: int,
|
||||
expected_quality: int
|
||||
) -> int:
|
||||
var modifier := int(_mode(policy)[&"satisfaction_delta"])
|
||||
# GAME_SPECIFICATION 7.4 includes a difference of exactly twenty points.
|
||||
if modifier < 0 and quality - expected_quality >= 20:
|
||||
# Keep the same nearest-integer rule for a half penalty: -2.5 becomes -3.
|
||||
@warning_ignore("integer_division")
|
||||
return -((-modifier + 1) / 2)
|
||||
return modifier
|
||||
|
||||
|
||||
static func validate_patch(patch: Dictionary) -> Dictionary:
|
||||
if _has(patch, &"price_mode"):
|
||||
var mode: Variant = _value(patch, &"price_mode", DEFAULT_MODE)
|
||||
if not (mode is String or mode is StringName) or not _known_mode(StringName(mode)):
|
||||
return _invalid(&"INVALID_PRICE_MODE", "価格設定は5段階のいずれかを選んでください。")
|
||||
if _has(patch, &"business_open") and not _value(patch, &"business_open", true) is bool:
|
||||
return _invalid(&"INVALID_BUSINESS_OPEN", "営業状態は営業中または休業を指定してください。")
|
||||
var normalized: Dictionary = {}
|
||||
for raw_key: Variant in patch:
|
||||
if not (raw_key is String or raw_key is StringName):
|
||||
return _invalid(&"INVALID_POLICY_KEY", "施設方針の項目名が正しくありません。")
|
||||
var value: Variant = patch[raw_key]
|
||||
normalized[StringName(raw_key)] = value.duplicate(true) if value is Array or value is Dictionary else value
|
||||
if normalized.has(&"price_mode"):
|
||||
normalized[&"price_mode"] = StringName(normalized[&"price_mode"])
|
||||
return {&"valid": true, &"reason": &"", &"message": "", &"patch": normalized}
|
||||
|
||||
|
||||
static func _mode(policy: Dictionary) -> Dictionary:
|
||||
var value: Variant = _value(policy, &"price_mode", DEFAULT_MODE)
|
||||
if value is String or value is StringName:
|
||||
for mode: Dictionary in MODES:
|
||||
if mode[&"id"] == StringName(value):
|
||||
return mode
|
||||
return MODES[2]
|
||||
|
||||
|
||||
static func _known_mode(mode_id: StringName) -> bool:
|
||||
for mode: Dictionary in MODES:
|
||||
if mode[&"id"] == mode_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
static func _invalid(reason: StringName, message: String) -> Dictionary:
|
||||
return {&"valid": false, &"reason": reason, &"message": message, &"patch": {}}
|
||||
|
||||
|
||||
static func _has(source: Dictionary, key: StringName) -> bool:
|
||||
return source.has(key) or source.has(String(key))
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4hh37purloll
|
||||
@@ -15,6 +15,7 @@ const FacilityInstanceType = preload("res://scripts/facility/facility_instance.g
|
||||
const ServiceRequestType = preload("res://scripts/facility/service_request.gd")
|
||||
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
const FacilityPricingType = preload("res://scripts/facility/facility_pricing.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const STAFF_REPLACEMENT_GRACE_MINUTES := 5
|
||||
@@ -175,15 +176,22 @@ func set_policy(
|
||||
return _not_found("Facility instance", instance_id)
|
||||
if patch.is_empty():
|
||||
return _invalid("Policy patch cannot be empty.")
|
||||
var validation := FacilityPricingType.validate_patch(patch)
|
||||
if not bool(validation[&"valid"]):
|
||||
return _invalid(String(validation[&"message"]), {&"reason": validation[&"reason"]})
|
||||
var normalized_patch: Dictionary = validation[&"patch"]
|
||||
if effective_at >= 0 and effective_at > current_minute:
|
||||
instance.schedule_policy_patch(patch, effective_at)
|
||||
instance.schedule_policy_patch(normalized_patch, effective_at)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"instance_id": instance_id,
|
||||
&"pending_revision": instance.policy_pending_revision,
|
||||
&"effective_at": effective_at,
|
||||
})
|
||||
var changed := instance.apply_policy_patch(patch)
|
||||
var changed := instance.apply_policy_patch(normalized_patch)
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(current_minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"instance_id": instance_id,
|
||||
@@ -205,7 +213,7 @@ func create_request(
|
||||
return null
|
||||
var quotation := options.duplicate(true)
|
||||
if not _has_value(quotation, &"quoted_price"):
|
||||
quotation[&"quoted_price"] = instance.base_price
|
||||
quotation[&"quoted_price"] = FacilityPricingType.quote(instance.base_price, instance.policy_current)
|
||||
if not _has_value(quotation, &"expected_quality"):
|
||||
quotation[&"expected_quality"] = instance.base_quality
|
||||
if not _has_value(quotation, &"service_minutes"):
|
||||
@@ -560,6 +568,11 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if instance == null or restored_instances.has(instance.instance_id):
|
||||
last_error = "Facility snapshot contains an invalid or duplicate instance."
|
||||
return false
|
||||
if not _saved_policy_is_valid(instance.policy_current) or not _saved_policy_is_valid(instance.policy_pending):
|
||||
return false
|
||||
for active: ServiceInstance in instance.active_services.values():
|
||||
if not _saved_policy_is_valid(active.policy_snapshot):
|
||||
return false
|
||||
restored_instances[instance.instance_id] = instance
|
||||
|
||||
var restored_requests: Dictionary = {}
|
||||
@@ -571,6 +584,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if request == null or restored_requests.has(request.request_id):
|
||||
last_error = "Facility snapshot contains an invalid or duplicate request."
|
||||
return false
|
||||
if not _saved_policy_is_valid(request.policy_snapshot):
|
||||
return false
|
||||
restored_requests[request.request_id] = request
|
||||
|
||||
var restored_history: Dictionary = {}
|
||||
@@ -582,6 +597,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if service == null or not service.is_terminal() or restored_history.has(service.service_id):
|
||||
last_error = "Facility snapshot contains invalid service history."
|
||||
return false
|
||||
if not _saved_policy_is_valid(service.policy_snapshot):
|
||||
return false
|
||||
restored_history[service.service_id] = service
|
||||
|
||||
var restored_known_instances: Variant = _id_set(_value(snapshot, &"known_instance_ids", []))
|
||||
@@ -619,6 +636,14 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func _saved_policy_is_valid(policy: Dictionary) -> bool:
|
||||
var validation := FacilityPricingType.validate_patch(policy)
|
||||
if not bool(validation[&"valid"]):
|
||||
last_error = "Facility snapshot contains an invalid policy: %s" % validation[&"reason"]
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _validate_restored_graph(
|
||||
instances: Dictionary,
|
||||
requests: Dictionary,
|
||||
|
||||
@@ -666,6 +666,41 @@ func enter_queue(
|
||||
return {&"ok": true, &"patience_remaining": guest.patience_remaining}
|
||||
|
||||
|
||||
## Closure asks the guest to choose again, without charging a patience penalty.
|
||||
## The callback releases the owning facility request and inventory reservations.
|
||||
func release_queue_for_replanning(
|
||||
guest_id: StringName,
|
||||
absolute_minute: int,
|
||||
release_callback: Callable = Callable()
|
||||
) -> Dictionary:
|
||||
if _external_mutation_blocked:
|
||||
return _error_result("Guest mutation is blocked while committed notifications are delivered.")
|
||||
var guest := get_guest(guest_id)
|
||||
if guest == null or guest.state != GuestRuntimeType.State.QUEUING:
|
||||
return _error_result("Guest is not in a queue.")
|
||||
if not _transition_is_available(guest, GuestRuntimeType.State.DECIDING, absolute_minute):
|
||||
return _error_result("Guest cannot atomically replan the queue in this tick.")
|
||||
var facility_id := guest.target_facility_id
|
||||
var request_id := guest.queue_request_id
|
||||
if facility_id.is_empty() or request_id.is_empty():
|
||||
return _error_result("Queued guest is missing a facility or request.")
|
||||
if release_callback.is_valid():
|
||||
var external_result: Variant = release_callback.call(guest_id, facility_id, request_id)
|
||||
if not _callback_succeeded(external_result):
|
||||
return _error_result("Facility queue removal failed; guest state was not changed.")
|
||||
guest.target_facility_id = &""
|
||||
guest.queue_request_id = &""
|
||||
guest.path_handle = null
|
||||
_apply_transition(guest, GuestRuntimeType.State.DECIDING, absolute_minute, &"FACILITY_CLOSED")
|
||||
last_error = ""
|
||||
return {
|
||||
&"ok": true,
|
||||
&"facility_id": facility_id,
|
||||
&"request_id": request_id,
|
||||
&"patience_remaining": guest.patience_remaining,
|
||||
}
|
||||
|
||||
|
||||
func tick_queue_patience(
|
||||
guest_id: StringName,
|
||||
absolute_minute: int,
|
||||
|
||||
@@ -216,6 +216,8 @@ func _pickup(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, mi
|
||||
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
||||
if source == null or not _cell_touches_facility(staff.current_cell, source):
|
||||
return _block_before_pickup(request, "INVALID_PICKUP_ACCESS", minute) and false
|
||||
if not source.operator_open:
|
||||
return _block_before_pickup(request, "SOURCE_CLOSED", minute) and false
|
||||
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
||||
if destination == null or not destination.operator_open or not _has_storage_space(destination, request.total_weight):
|
||||
return _block_before_pickup(request, "DESTINATION_FULL_OR_CLOSED", minute) and false
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
class_name BusinessManagementPanel
|
||||
extends VBoxContainer
|
||||
|
||||
## Policy drafts are local; accepted commands and read models belong to Runtime.
|
||||
signal status_requested(message: String, success: bool)
|
||||
signal action_requested(action: StringName)
|
||||
|
||||
const KEY_PREFIX := "ui.business."
|
||||
const COMMAND: StringName = &"set_facility_policy"
|
||||
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与は発生します。"
|
||||
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与も発生します。"
|
||||
const STATE_NAMES := {&"BROKEN": "故障中", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力不足", &"NO_STAFF": "職員不足", &"CLOSED": "休業中", &"DEGRADED": "状態悪化", &"OPEN": "稼働中"}
|
||||
const REASONS := {
|
||||
&"FACILITY_NOT_FOUND": "施設が見つかりません。選び直してください。",
|
||||
&"INVALID_POLICY": "営業設定を確認してください。",
|
||||
&"INVALID_PRICE_MODE": "料金を選び直してください。",
|
||||
&"INVALID_PAYLOAD": "営業設定を確認してください。",
|
||||
&"FACILITY_CLOSED": "この施設は利用できません。",
|
||||
&"BANKRUPT": "運営終了後は変更できません。",
|
||||
}
|
||||
var facility_option: OptionButton
|
||||
var price_option: OptionButton
|
||||
var business_open_button: CheckButton
|
||||
var quote_label: Label
|
||||
var effect_label: Label
|
||||
var facility_status_label: Label
|
||||
var today_label: Label
|
||||
var explanation_label: Label
|
||||
var apply_button: Button
|
||||
var result_label: Label
|
||||
var selected_facility_id: StringName = &""
|
||||
|
||||
var _runtime: Variant
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _drafts: Dictionary = {}
|
||||
var _pending_commands: Dictionary = {}
|
||||
var _queue_instance_id := 0
|
||||
var _sequence := 1
|
||||
var _refreshing := false
|
||||
var _built := false
|
||||
|
||||
|
||||
func configure(runtime: Variant, style_button := Callable()) -> void:
|
||||
_runtime = runtime
|
||||
_style_button = style_button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "BusinessManagementPanel"
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 6)
|
||||
_build_interface()
|
||||
_built = true
|
||||
if _runtime != null:
|
||||
if _runtime.has_signal(&"snapshot_published"):
|
||||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||||
if _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
refresh()
|
||||
|
||||
|
||||
func _build_interface() -> void:
|
||||
add_child(_label("営業 · 料金と受付", "BusinessSummary"))
|
||||
add_child(_label("設定する施設"))
|
||||
facility_option = _option("BusinessFacilityOption")
|
||||
facility_option.item_selected.connect(func(index: int) -> void:
|
||||
selected_facility_id = StringName(facility_option.get_item_metadata(index))
|
||||
refresh())
|
||||
add_child(facility_option)
|
||||
facility_status_label = _label("", "BusinessFacilityStatus")
|
||||
add_child(facility_status_label)
|
||||
add_child(_label("新規の料金"))
|
||||
price_option = _option("BusinessPriceOption")
|
||||
price_option.item_selected.connect(func(_index: int) -> void: _store_draft())
|
||||
add_child(price_option)
|
||||
quote_label = _label("", "BusinessPriceQuote")
|
||||
quote_label.add_theme_font_size_override("font_size", 16)
|
||||
add_child(quote_label)
|
||||
effect_label = _label("", "BusinessPriceEffect")
|
||||
add_child(effect_label)
|
||||
business_open_button = CheckButton.new()
|
||||
business_open_button.name = "BusinessOpenButton"
|
||||
business_open_button.text = "新規の受付を開く"
|
||||
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
|
||||
business_open_button.add_theme_color_override(color_name, Color("55371e"))
|
||||
business_open_button.add_theme_color_override("font_disabled_color", Color("766650"))
|
||||
business_open_button.add_theme_font_size_override("font_size", 13)
|
||||
business_open_button.toggled.connect(func(_enabled: bool) -> void: _store_draft())
|
||||
add_child(business_open_button)
|
||||
apply_button = _button("営業設定を適用", "ApplyBusinessPolicyButton", submit_policy)
|
||||
add_child(apply_button)
|
||||
explanation_label = _label(GUEST_POLICY_EXPLANATION, "BusinessPolicyExplanation")
|
||||
add_child(explanation_label)
|
||||
add_child(HSeparator.new())
|
||||
today_label = _label("", "BusinessTodayResults")
|
||||
add_child(today_label)
|
||||
var actions := HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 4)
|
||||
for entry: Array in [[&"staff", "従業員"], [&"product", "商品"], [&"ledger", "帳簿"]]:
|
||||
var button := _button(entry[1], "BusinessAction_%s" % entry[0], func() -> void: action_requested.emit(entry[0]))
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
actions.add_child(button)
|
||||
add_child(actions)
|
||||
result_label = _label("", "BusinessResult")
|
||||
add_child(result_label)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not _built:
|
||||
return
|
||||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||||
if queue != null and queue.get_instance_id() != _queue_instance_id:
|
||||
_queue_instance_id = queue.get_instance_id()
|
||||
_drafts.clear()
|
||||
facility_option.get_popup().hide()
|
||||
price_option.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
_report = {}
|
||||
if _runtime != null and _runtime.has_method(&"get_business_management_report"):
|
||||
var value: Variant = _runtime.call(&"get_business_management_report")
|
||||
if value is Dictionary:
|
||||
_report = value
|
||||
_refreshing = true
|
||||
_fill_facilities()
|
||||
var row := _facility_row(selected_facility_id)
|
||||
var draft := _selected_draft(row)
|
||||
_fill_modes(StringName(draft.get(&"price_mode", &"standard")))
|
||||
business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true)))
|
||||
var pending := _has_pending(selected_facility_id)
|
||||
var can_price := bool(row.get(&"can_price", false))
|
||||
var is_supply := StringName(row.get(&"definition_id", &"")) in [&"kitchen", &"warehouse"]
|
||||
business_open_button.text = "営業する" if is_supply else "新規の受付を開く"
|
||||
explanation_label.text = SUPPLY_POLICY_EXPLANATION if is_supply else GUEST_POLICY_EXPLANATION
|
||||
facility_option.disabled = _rows(&"facilities").is_empty()
|
||||
price_option.disabled = row.is_empty() or not can_price or pending or price_option.item_count == 0
|
||||
business_open_button.disabled = row.is_empty() or pending
|
||||
apply_button.disabled = row.is_empty() or pending or not _is_changed(row, draft)
|
||||
apply_button.text = "設定を受理済み…" if pending else "営業設定を適用"
|
||||
if row.is_empty():
|
||||
facility_status_label.text = "施設が完成すると営業を設定できます。"
|
||||
quote_label.text = "施設を選んでください。"
|
||||
effect_label.text = ""
|
||||
today_label.text = "本日の実績はまだありません。"
|
||||
else:
|
||||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_count", 0)), int(row.get(&"active_count", 0))]
|
||||
if is_supply:
|
||||
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||||
if can_price:
|
||||
var mode := StringName(draft.get(&"price_mode", &"standard"))
|
||||
var quoted := FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode})
|
||||
var unit := String({&"standard_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回"))
|
||||
quote_label.text = "設定後 %dG / %s\n現在 %dG · 標準 %dG" % [quoted, unit, int(row.get(&"price", 0)), int(row.get(&"base_price", 0))]
|
||||
effect_label.text = _price_effect(mode)
|
||||
else:
|
||||
quote_label.text = "この施設に利用料金はありません。"
|
||||
effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。"
|
||||
today_label.text = "本日の記帳 · 06:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌朝の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))]
|
||||
_refreshing = false
|
||||
|
||||
|
||||
func select_facility(id: StringName) -> void:
|
||||
selected_facility_id = id
|
||||
refresh()
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
return _report.duplicate(true)
|
||||
|
||||
|
||||
func submit_policy() -> void:
|
||||
var row := _facility_row(selected_facility_id)
|
||||
if row.is_empty() or _has_pending(selected_facility_id):
|
||||
return
|
||||
var draft := _selected_draft(row)
|
||||
if not _is_changed(row, draft):
|
||||
return
|
||||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||||
_set_result("営業サービスへ接続できません。", false)
|
||||
return
|
||||
var patch: Dictionary = {&"business_open": bool(draft.get(&"business_open", true))}
|
||||
if bool(row.get(&"can_price", false)):
|
||||
patch[&"price_mode"] = StringName(draft.get(&"price_mode", &"standard"))
|
||||
var payload := {&"facility_id": selected_facility_id, &"patch": patch}
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
var queued := false
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if queue != null:
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
|
||||
if result.is_success() and queued:
|
||||
_pending_commands[int(result.command_id)] = payload.duplicate(true)
|
||||
_set_result("営業設定を受理しました。次の時間更新で反映します。", true)
|
||||
else:
|
||||
_show_result(result, payload)
|
||||
refresh()
|
||||
|
||||
|
||||
func _show_result(result: Variant, payload: Dictionary) -> void:
|
||||
if not result.is_success():
|
||||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||||
var reason := StringName(details.get(&"reason", &"UNAVAILABLE"))
|
||||
_set_result(String(REASONS.get(reason, "営業設定を反映できませんでした(%s)。" % reason)), false)
|
||||
return
|
||||
var id := StringName(payload.get(&"facility_id", &""))
|
||||
_drafts.erase(id)
|
||||
var row := _facility_row(id)
|
||||
_set_result("%sの営業設定を反映しました。" % String(row.get(&"display_name", "施設")), true)
|
||||
|
||||
|
||||
func _selected_draft(row: Dictionary) -> Dictionary:
|
||||
for payload: Dictionary in _pending_commands.values():
|
||||
if StringName(payload.get(&"facility_id", &"")) == selected_facility_id:
|
||||
var draft := row.duplicate(true)
|
||||
draft.merge(payload.get(&"patch", {}), true)
|
||||
return draft
|
||||
return _drafts.get(selected_facility_id, row)
|
||||
|
||||
|
||||
func _is_changed(row: Dictionary, draft: Dictionary) -> bool:
|
||||
return bool(row.get(&"business_open", true)) != bool(draft.get(&"business_open", true)) or (bool(row.get(&"can_price", false)) and StringName(row.get(&"price_mode", &"standard")) != StringName(draft.get(&"price_mode", &"standard")))
|
||||
|
||||
|
||||
func _store_draft() -> void:
|
||||
if _refreshing or selected_facility_id.is_empty() or _has_pending(selected_facility_id):
|
||||
return
|
||||
var mode := &"standard"
|
||||
if price_option.selected >= 0:
|
||||
mode = StringName(price_option.get_item_metadata(price_option.selected))
|
||||
_drafts[selected_facility_id] = {&"business_open": business_open_button.button_pressed, &"price_mode": mode}
|
||||
refresh()
|
||||
|
||||
|
||||
func _synchronize_commands(queue: Variant) -> void:
|
||||
_pending_commands.clear()
|
||||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||||
var key := String(metadata.get("idempotency_key", ""))
|
||||
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
if StringName(command.get("command_type", &"")) == COMMAND:
|
||||
_pending_commands[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
|
||||
|
||||
|
||||
func _has_pending(id: StringName) -> bool:
|
||||
for payload: Dictionary in _pending_commands.values():
|
||||
if StringName(payload.get(&"facility_id", &"")) == id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if not _pending_commands.has(int(result.command_id)):
|
||||
return
|
||||
var payload: Dictionary = _pending_commands[int(result.command_id)]
|
||||
_pending_commands.erase(int(result.command_id))
|
||||
_show_result(result, payload)
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||||
if is_visible_in_tree():
|
||||
refresh()
|
||||
|
||||
|
||||
func _fill_facilities() -> void:
|
||||
if facility_option.get_popup().visible:
|
||||
return
|
||||
facility_option.clear()
|
||||
var rows := _rows(&"facilities")
|
||||
if rows.is_empty():
|
||||
facility_option.add_item("完成した施設がありません")
|
||||
facility_option.set_item_metadata(0, &"")
|
||||
selected_facility_id = &""
|
||||
return
|
||||
var selected_index := 0
|
||||
for row: Dictionary in rows:
|
||||
facility_option.add_item(_facility_name(row))
|
||||
var index := facility_option.item_count - 1
|
||||
var id := StringName(row.get(&"facility_id", &""))
|
||||
facility_option.set_item_metadata(index, id)
|
||||
if id == selected_facility_id:
|
||||
selected_index = index
|
||||
facility_option.select(selected_index)
|
||||
selected_facility_id = StringName(facility_option.get_item_metadata(selected_index))
|
||||
|
||||
|
||||
func _fill_modes(selected: StringName) -> void:
|
||||
if price_option.get_popup().visible:
|
||||
return
|
||||
price_option.clear()
|
||||
var selected_index := 0
|
||||
for mode: Dictionary in _rows(&"modes"):
|
||||
price_option.add_item("%s(%d%%)" % [String(mode.get(&"label", "料金")), int(mode.get(&"percent", 100))])
|
||||
var index := price_option.item_count - 1
|
||||
price_option.set_item_metadata(index, StringName(mode.get(&"id", &"standard")))
|
||||
if StringName(mode.get(&"id", &"standard")) == selected:
|
||||
selected_index = index
|
||||
if price_option.item_count > 0:
|
||||
price_option.select(selected_index)
|
||||
|
||||
|
||||
func _facility_name(row: Dictionary) -> String:
|
||||
var label := String(row.get(&"display_name", row.get(&"facility_id", "施設")))
|
||||
var origin: Variant = row.get(&"origin", null)
|
||||
var layer := "裏" if int(row.get(&"layer", 0)) == 1 else "表"
|
||||
if origin is Vector2i or origin is Vector3i:
|
||||
label += " (%d,%d・%s)" % [origin.x, origin.y, layer]
|
||||
elif origin is Dictionary:
|
||||
label += " (%d,%d・%s)" % [int(origin.get(&"x", 0)), int(origin.get(&"y", 0)), layer]
|
||||
return label
|
||||
|
||||
|
||||
func _price_effect(mode: StringName) -> String:
|
||||
match mode:
|
||||
&"charitable": return "お財布にやさしい料金。満足度が大きく上がる一方、1回の売上は少なくなります。"
|
||||
&"affordable": return "手頃な料金。満足度が上がり、1回の売上は少し少なくなります。"
|
||||
&"expensive": return "1回の売上が増えますが、満足度は下がります。お客様の懐具合にもご注意を。"
|
||||
&"exorbitant": return "とても高い料金。満足度が大きく下がり、お客様が支払えない場合もあります。"
|
||||
_: return "基準となる料金。価格による満足度の増減はありません。"
|
||||
|
||||
|
||||
func _rows(key: StringName) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
var value: Variant = _report.get(key, [])
|
||||
if value is Array:
|
||||
for row: Variant in value:
|
||||
if row is Dictionary:
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
func _facility_row(id: StringName) -> Dictionary:
|
||||
for row: Dictionary in _rows(&"facilities"):
|
||||
if StringName(row.get(&"facility_id", &"")) == id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _set_result(message: String, success: bool) -> void:
|
||||
result_label.text = message
|
||||
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
|
||||
status_requested.emit(message, success)
|
||||
|
||||
|
||||
func _label(value: String, node_name := "") -> Label:
|
||||
var label := Label.new()
|
||||
if not node_name.is_empty():
|
||||
label.name = node_name
|
||||
label.text = value
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
return label
|
||||
|
||||
|
||||
func _button(value: String, node_name: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.name = node_name
|
||||
button.text = value
|
||||
button.custom_minimum_size.y = 30
|
||||
button.add_theme_font_size_override("font_size", 13)
|
||||
button.pressed.connect(callback)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(button)
|
||||
return button
|
||||
|
||||
|
||||
func _option(node_name: String) -> OptionButton:
|
||||
var picker := OptionButton.new()
|
||||
picker.name = node_name
|
||||
picker.fit_to_longest_item = false
|
||||
picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
picker.custom_minimum_size.y = 30
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(picker)
|
||||
return picker
|
||||
@@ -0,0 +1 @@
|
||||
uid://byrn3mfhqhpqj
|
||||
@@ -104,7 +104,7 @@ func refresh() -> void:
|
||||
var other := 0
|
||||
for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]:
|
||||
other += int(totals.get(key, 0))
|
||||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||||
var lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"]
|
||||
var rows: Dictionary = summary.get(&"facility_profit_loss", {})
|
||||
var ids := rows.keys()
|
||||
|
||||
@@ -41,6 +41,7 @@ const REASONS := {
|
||||
&"CARRIER_TASK_MISSING": "運搬作業との接続を確認してください。",
|
||||
&"CARRIER_UNAVAILABLE": "担当の運搬員が現在作業できません。",
|
||||
&"SOURCE_STOCK_EXPIRED": "荷元の在庫が消費期限を過ぎました。",
|
||||
&"SOURCE_CLOSED": "荷元の施設が休業しています。",
|
||||
&"WAITING_FOR_SOURCE_STOCK": "荷元への入荷を待っています。",
|
||||
&"SOURCE_WEIGHT_CHANGED": "荷物の重量が変わったため再手配が必要です。",
|
||||
&"PICKUP_FAILED": "積込できませんでした。再手配を待っています。",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d37y3hbvoei5k
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqsf72kvrm7qn
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user