diff --git a/Makefile b/Makefile index cc5a024..62fae34 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 7be5a55..3deab3c 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.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) diff --git a/data/facilities/cafeteria.tres b/data/facilities/cafeteria.tres index abba935..4b27c85 100644 --- a/data/facilities/cafeteria.tres +++ b/data/facilities/cafeteria.tres @@ -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"]) diff --git a/data/facilities/reception.tres b/data/facilities/reception.tres index a7c1dd1..c740c70 100644 --- a/data/facilities/reception.tres +++ b/data/facilities/reception.tres @@ -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 diff --git a/data/facilities/standard_room.tres b/data/facilities/standard_room.tres index 546904a..6145601 100644 --- a/data/facilities/standard_room.tres +++ b/data/facilities/standard_room.tres @@ -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"]) diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 8491110..4dda1b3 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -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 = &"" diff --git a/scripts/core/simulation_digest.gd b/scripts/core/simulation_digest.gd index ac3fbef..57eb3ac 100644 --- a/scripts/core/simulation_digest.gd +++ b/scripts/core/simulation_digest.gd @@ -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 diff --git a/scripts/facility/facility_instance.gd b/scripts/facility/facility_instance.gd index 94bcc8a..c1073f6 100644 --- a/scripts/facility/facility_instance.gd +++ b/scripts/facility/facility_instance.gd @@ -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 diff --git a/scripts/facility/facility_pricing.gd b/scripts/facility/facility_pricing.gd new file mode 100644 index 0000000..8d99bda --- /dev/null +++ b/scripts/facility/facility_pricing.gd @@ -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)) diff --git a/scripts/facility/facility_pricing.gd.uid b/scripts/facility/facility_pricing.gd.uid new file mode 100644 index 0000000..d4329b8 --- /dev/null +++ b/scripts/facility/facility_pricing.gd.uid @@ -0,0 +1 @@ +uid://d4hh37purloll diff --git a/scripts/facility/facility_service.gd b/scripts/facility/facility_service.gd index ec62d7b..4a94827 100644 --- a/scripts/facility/facility_service.gd +++ b/scripts/facility/facility_service.gd @@ -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, diff --git a/scripts/guest/guest_service.gd b/scripts/guest/guest_service.gd index bc6d0f9..3b957ac 100644 --- a/scripts/guest/guest_service.gd +++ b/scripts/guest/guest_service.gd @@ -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, diff --git a/scripts/supply/supply_logistics_bridge.gd b/scripts/supply/supply_logistics_bridge.gd index b719668..8dabe57 100644 --- a/scripts/supply/supply_logistics_bridge.gd +++ b/scripts/supply/supply_logistics_bridge.gd @@ -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 diff --git a/scripts/ui/business_management_panel.gd b/scripts/ui/business_management_panel.gd new file mode 100644 index 0000000..868e593 --- /dev/null +++ b/scripts/ui/business_management_panel.gd @@ -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 diff --git a/scripts/ui/business_management_panel.gd.uid b/scripts/ui/business_management_panel.gd.uid new file mode 100644 index 0000000..60d193e --- /dev/null +++ b/scripts/ui/business_management_panel.gd.uid @@ -0,0 +1 @@ +uid://byrn3mfhqhpqj diff --git a/scripts/ui/operating_ledger_panel.gd b/scripts/ui/operating_ledger_panel.gd index c6a124e..6a72654 100644 --- a/scripts/ui/operating_ledger_panel.gd +++ b/scripts/ui/operating_ledger_panel.gd @@ -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() diff --git a/scripts/ui/supply_management_panel.gd b/scripts/ui/supply_management_panel.gd index b587e74..79efe87 100644 --- a/scripts/ui/supply_management_panel.gd +++ b/scripts/ui/supply_management_panel.gd @@ -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": "積込できませんでした。再手配を待っています。", diff --git a/tests/business/test_business_closure.gd b/tests/business/test_business_closure.gd new file mode 100644 index 0000000..77e6c77 --- /dev/null +++ b/tests/business/test_business_closure.gd @@ -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 diff --git a/tests/business/test_business_closure.gd.uid b/tests/business/test_business_closure.gd.uid new file mode 100644 index 0000000..9e19002 --- /dev/null +++ b/tests/business/test_business_closure.gd.uid @@ -0,0 +1 @@ +uid://dv11kpqhqkmtg diff --git a/tests/business/test_business_operations.gd b/tests/business/test_business_operations.gd new file mode 100644 index 0000000..10acb42 --- /dev/null +++ b/tests/business/test_business_operations.gd @@ -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) diff --git a/tests/business/test_business_operations.gd.uid b/tests/business/test_business_operations.gd.uid new file mode 100644 index 0000000..94a8f4d --- /dev/null +++ b/tests/business/test_business_operations.gd.uid @@ -0,0 +1 @@ +uid://xnbsrxgxnvsx diff --git a/tests/performance/fixtures/simulation_digest_v1_reference.gd b/tests/performance/fixtures/simulation_digest_v1_reference.gd new file mode 100644 index 0000000..ea7f933 --- /dev/null +++ b/tests/performance/fixtures/simulation_digest_v1_reference.gd @@ -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()) diff --git a/tests/performance/fixtures/simulation_digest_v1_reference.gd.uid b/tests/performance/fixtures/simulation_digest_v1_reference.gd.uid new file mode 100644 index 0000000..8932ef9 --- /dev/null +++ b/tests/performance/fixtures/simulation_digest_v1_reference.gd.uid @@ -0,0 +1 @@ +uid://cyhefo00b7wkr diff --git a/tests/performance/test_digest_performance.gd b/tests/performance/test_digest_performance.gd new file mode 100644 index 0000000..c2182d3 --- /dev/null +++ b/tests/performance/test_digest_performance.gd @@ -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) diff --git a/tests/performance/test_digest_performance.gd.uid b/tests/performance/test_digest_performance.gd.uid new file mode 100644 index 0000000..50d4f71 --- /dev/null +++ b/tests/performance/test_digest_performance.gd.uid @@ -0,0 +1 @@ +uid://c78f41p838v73 diff --git a/tests/s2/test_facility_pricing.gd b/tests/s2/test_facility_pricing.gd new file mode 100644 index 0000000..3517c85 --- /dev/null +++ b/tests/s2/test_facility_pricing.gd @@ -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) diff --git a/tests/s2/test_facility_pricing.gd.uid b/tests/s2/test_facility_pricing.gd.uid new file mode 100644 index 0000000..86963a0 --- /dev/null +++ b/tests/s2/test_facility_pricing.gd.uid @@ -0,0 +1 @@ +uid://d37y3hbvoei5k diff --git a/tests/s2/test_facility_service.gd b/tests/s2/test_facility_service.gd index 51d0a5b..3535733 100644 --- a/tests/s2/test_facility_service.gd +++ b/tests/s2/test_facility_service.gd @@ -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) diff --git a/tests/s2/test_runtime_operations_cycle.gd b/tests/s2/test_runtime_operations_cycle.gd index 10dd6d8..ba1680b 100644 --- a/tests/s2/test_runtime_operations_cycle.gd +++ b/tests/s2/test_runtime_operations_cycle.gd @@ -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(): diff --git a/tests/s3/test_runtime_actor_cycle.gd b/tests/s3/test_runtime_actor_cycle.gd index ba07925..709c18e 100644 --- a/tests/s3/test_runtime_actor_cycle.gd +++ b/tests/s3/test_runtime_actor_cycle.gd @@ -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 diff --git a/tests/supply/test_normal_supply_chain.gd b/tests/supply/test_normal_supply_chain.gd index fd0f8b3..19e08d9 100644 --- a/tests/supply/test_normal_supply_chain.gd +++ b/tests/supply/test_normal_supply_chain.gd @@ -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) diff --git a/tests/supply/test_supply_logistics_bridge.gd b/tests/supply/test_supply_logistics_bridge.gd index 9119d5f..9e3bf48 100644 --- a/tests/supply/test_supply_logistics_bridge.gd +++ b/tests/supply/test_supply_logistics_bridge.gd @@ -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) diff --git a/tests/ui/test_business_management.gd b/tests/ui/test_business_management.gd new file mode 100644 index 0000000..a9274a6 --- /dev/null +++ b/tests/ui/test_business_management.gd @@ -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) diff --git a/tests/ui/test_business_management.gd.uid b/tests/ui/test_business_management.gd.uid new file mode 100644 index 0000000..a5e19a4 --- /dev/null +++ b/tests/ui/test_business_management.gd.uid @@ -0,0 +1 @@ +uid://bqsf72kvrm7qn diff --git a/tests/ui/test_main_dashboard.gd b/tests/ui/test_main_dashboard.gd index d175b17..07a7baa 100644 --- a/tests/ui/test_main_dashboard.gd +++ b/tests/ui/test_main_dashboard.gd @@ -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