From 285528b7a3b1b46a2a67af5aa90d9085539b42f4 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Thu, 1 Oct 2026 02:43:39 +0900 Subject: [PATCH] Expose pending service pricing in facility UI Show normal usage fees separately from the unspecified desire-match surcharge. Preserve free reception and omit guest pricing from warehouse and cleaning rooms. Validation: 155 suites / 12,310 checks; focused 700 checks; strict state and event equivalence; 16 normal and compact FRONT/BACK captures. --- autoload/simulation_runtime.gd | 13 +- scripts/facility/facility_pricing.gd | 14 ++ scripts/ui/business_management_panel.gd | 13 +- .../test_pending_service_pricing_report.gd | 217 ++++++++++++++++++ ...test_pending_service_pricing_report.gd.uid | 1 + 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 tests/mvp/test_pending_service_pricing_report.gd create mode 100644 tests/mvp/test_pending_service_pricing_report.gd.uid diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index ac206e1..4047725 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -12182,6 +12182,14 @@ func get_facility_business_price(facility: FacilityInstance) -> int: return FacilityPricing.apply_multiplier(donation, get_floor_price_multiplier(facility.origin.y)) +## Owned presentation data; accepted request/service quotations stay frozen. +## Matching-surcharge charging remains pending until its inputs are specified. +func get_facility_service_pricing_readmodel(facility: FacilityInstance) -> Dictionary: + return FacilityPricing.pending_service_readmodel( + get_facility_business_price(facility), facility.service_attribute + ) + + func _business_base_price(facility: FacilityInstance) -> int: return 0 if facility.definition_id == &"reception" else facility.base_price @@ -12206,6 +12214,7 @@ func get_business_management_report() -> Dictionary: continue var amounts: Dictionary = totals.get(facility.instance_id, {}) var base := _business_base_price(facility) + var service_pricing := get_facility_service_pricing_readmodel(facility) var queued_customers: Dictionary = {} for request: ServiceRequest in facility_service.get_queue(facility.instance_id): for guest_id: StringName in request.guest_ids: @@ -12213,6 +12222,7 @@ func get_business_management_report() -> Dictionary: rows.append({ &"facility_id": facility.instance_id, &"definition_id": facility.definition_id, + &"category": definition.category, &"display_name": definition.display_name, &"origin": Vector2i(facility.origin.x, facility.origin.y), &"layer": facility.origin.z, @@ -12220,7 +12230,8 @@ func get_business_management_report() -> Dictionary: &"business_open": facility.operator_open, &"price_mode": StringName(facility.policy_current.get(&"price_mode", &"standard")), &"base_price": base, - &"price": get_facility_business_price(facility), + &"price": service_pricing[&"normal_price"], + &"service_pricing": service_pricing, &"floor_price_multiplier_bp": get_floor_price_multiplier(facility.origin.y), &"donation_multiplier": get_donation_multiplier(facility), &"faith_per_service": definition.faith_per_service, diff --git a/scripts/facility/facility_pricing.gd b/scripts/facility/facility_pricing.gd index 50d9855..ed5a608 100644 --- a/scripts/facility/facility_pricing.gd +++ b/scripts/facility/facility_pricing.gd @@ -44,6 +44,20 @@ static func quote_service(base_price: int, policy: Dictionary, service_attribute &"surcharge": surcharge, &"total": base + surcharge, &"eligible": true} +## The normal fee follows its existing charging contract. The added need-match +## rule has no approved assessment or amount. Null retains that uncertainty. +static func pending_service_readmodel(normal_price: int, service_attribute: StringName) -> Dictionary: + return { + &"normal_price": normal_price, + &"service_attribute": null if service_attribute.is_empty() else service_attribute, + &"deficient_attributes": null, + &"matches": null, + &"surcharge": null, + &"total": null, + &"resolved": false, + } + + static func satisfaction_modifier( policy: Dictionary, quality: int, diff --git a/scripts/ui/business_management_panel.gd b/scripts/ui/business_management_panel.gd index 068ba00..82370bb 100644 --- a/scripts/ui/business_management_panel.gd +++ b/scripts/ui/business_management_panel.gd @@ -11,6 +11,10 @@ const KEY_PREFIX := "ui.business." const COMMAND: StringName = &"set_facility_policy" const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与と施設維持費は発生します。" const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与と施設維持費も発生します。" +const SERVICE_ATTRIBUTE_NAMES := { + &"": "未設定", &"appetite": "食欲", &"relaxation": "リラックス欲", + &"sexual": "性欲", &"gambling": "ギャンブル欲", +} const STATE_NAMES := {&"BROKEN": "故障中", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力不足", &"NO_STAFF": "職員不足", &"CLOSED": "休業中", &"DEGRADED": "状態悪化", &"OPEN": "稼働中"} const REASONS := { &"FACILITY_NOT_FOUND": "施設が見つかりません。選び直してください。", @@ -134,6 +138,7 @@ func refresh() -> void: 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_guest_service := StringName(row.get(&"category", &"")) in [&"guest_service", &"restricted_service"] var is_cleaning := StringName(row.get(&"definition_id", &"")) == &"cleaning_room" var is_supply := StringName(row.get(&"definition_id", &"")) in [&"kitchen", &"warehouse"] business_open_button.text = "営業する" if is_supply else "新規の受付を開く" @@ -167,16 +172,20 @@ func refresh() -> void: var mode := StringName(draft.get(&"price_mode", &"standard")) var quoted := FacilityPricing.apply_multiplier(FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode}) * int(row.get(&"donation_multiplier", 1)), int(row.get(&"floor_price_multiplier_bp", 10000))) var unit := String({&"standard_room": "泊", &"premium_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))] + 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) if int(row.get(&"donation_multiplier", 1)) == 3: effect_label.text += "\nアメリア配置:寄付金3倍" if int(row.get(&"floor_price_multiplier_bp", 10000)) == 12000: effect_label.text += "\nアンリ配置フロア:利用料金+20%" else: - quote_label.text = "この施設に利用料金はありません。" + quote_label.text = "通常の利用料金はありません(現在 %dG)。" % int(row.get(&"price", 0)) if is_guest_service else "この施設に利用料金はありません。" effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。" if is_cleaning: effect_label.text = "清掃業務の有効・無効を設定できます。" + if can_price or is_guest_service: + var service_pricing: Dictionary = row.get(&"service_pricing", {}) + var attribute: Variant = service_pricing.get(&"service_attribute", null) + quote_label.text += "\nサービス属性:%s\n欲求一致加算:未設定" % String(SERVICE_ATTRIBUTE_NAMES.get(attribute, "未設定")) if int(row.get(&"faith_per_service", 0)) > 0: effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"]) today_label.text = "本日の記帳 · 00:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌日の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))] diff --git a/tests/mvp/test_pending_service_pricing_report.gd b/tests/mvp/test_pending_service_pricing_report.gd new file mode 100644 index 0000000..b717963 --- /dev/null +++ b/tests/mvp/test_pending_service_pricing_report.gd @@ -0,0 +1,217 @@ +extends SceneTree + +## Uses the real Main business controls and Runtime paid-service boundary. +## No amount, deficit threshold or production attribute assignment is introduced. +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _main: Control +var _panel: BusinessManagementPanel +var _room_id: StringName = &"" +var _workplace_id: StringName = &"" +var _reception_id: StringName = &"" +var _warehouse_id: StringName = &"" +var _cleaning_id: StringName = &"" + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + _clock.reset(1, 6, 0) + state.reset() + _runtime.initialize(_clock, state, 42602) + _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/pending_service_pricing_%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 controls") + if _panel == null: + _finish() + return + # Existing Anri fixture plus one paid cleaning room for non-customer UI. + for row: Array in [ + [&"reception", Vector3i(-6, 0, 0)], + [&"warehouse", Vector3i(-6, 0, 1)], + [&"premium_room", Vector3i(-6, 2, 0)], + [&"warehouse", Vector3i(-6, 2, 1)], + [&"service_stairs", Vector3i(-7, 1, 1)], + [&"service_stairs", Vector3i(-7, 2, 1)], + [&"cleaning_room", Vector3i(2, 0, 1)], + ]: + var built: CommandResult = _runtime.submit_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}) + _expect(built.is_success(), "normal paid fixture build %s: %s" % [row[0], built.message]) + if not built.is_success(): + _finish() + return + _clock.advance_minutes(_runtime.definition_registry.get_facility(row[0]).build_minutes) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"premium_room": + _room_id = facility.instance_id + elif facility.definition_id == &"reception": + _reception_id = facility.instance_id + elif facility.definition_id == &"cleaning_room": + _cleaning_id = facility.instance_id + elif facility.definition_id == &"warehouse": + if facility.origin.y == 2: + _workplace_id = facility.instance_id + else: + _warehouse_id = facility.instance_id + _expect(not _room_id.is_empty() and not _workplace_id.is_empty() and not _reception_id.is_empty() and not _warehouse_id.is_empty() and not _cleaning_id.is_empty(), "real customer and non-customer facilities complete before the report") + if _room_id.is_empty() or _workplace_id.is_empty() or _reception_id.is_empty() or _warehouse_id.is_empty() or _cleaning_id.is_empty(): + _finish() + return + _expect(_runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": &"heroine_anri"}).is_success(), "canonical free Anri remains recruitable") + var assignment: CommandResult = _runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": &"heroine_anri", &"facility_id": _workplace_id, &"template_id": &"DAY"}) + _expect(assignment.is_success(), "normal accessible workplace accepts Anri placement") + _expect(_runtime.get_facility_business_price(_room()) == 264, "approved room fee includes existing Anri20 percent") + _test_owned_pending_report() + await _test_normal_ui() + _test_frozen_service_price() + _finish() + + +func _test_owned_pending_report() -> void: + var before: Dictionary = _runtime.create_snapshot() + var before_bytes := var_to_bytes(before) + var before_digest := SimulationDigest.compute(before) + var readmodel: Dictionary = _runtime.get_facility_service_pricing_readmodel(_room()) + _expect(readmodel[&"normal_price"] == _runtime.get_facility_business_price(_room()), "readmodel uses actual existing normal charge") + _expect(readmodel[&"service_attribute"] == null and readmodel.has(&"deficient_attributes") and readmodel[&"deficient_attributes"] == null, "unassigned service attribute and unassessed deficits remain null") + _expect(_is_pending(readmodel), "unknown deficiency is neither nonmatch nor zero-G surcharge") + var report: Dictionary = _runtime.get_business_management_report() + var row := _row(report, _room_id) + _expect(not row.is_empty() and row[&"price"] == 264 and row[&"service_pricing"][&"normal_price"] == row[&"price"], "normal business report preserves its price and includes separate pending rule") + var row_before := row.duplicate(true) + var second: Dictionary = _runtime.get_facility_service_pricing_readmodel(_room()) + readmodel[&"normal_price"] = -900 + readmodel[&"surcharge"] = 0 + readmodel[&"resolved"] = true + _expect(_is_pending(second) and second[&"normal_price"] == 264, "independent readmodel does not share mutable caller data") + _expect(var_to_bytes(row) == var_to_bytes(row_before), "direct readmodel does not alias report readmodel") + (row[&"service_pricing"] as Dictionary).clear() + (report[&"facilities"] as Array).clear() + var fresh_row := _row(_runtime.get_business_management_report(), _room_id) + _expect(_is_pending(fresh_row[&"service_pricing"]) and fresh_row[&"price"] == 264, "mutating nested report output cannot alter subsequent reports") + var reception: FacilityInstance = _runtime.facility_service.get_instance(_reception_id) + var free_readmodel: Dictionary = _runtime.get_facility_service_pricing_readmodel(reception) + _expect(free_readmodel[&"normal_price"] == 0 and _is_pending(free_readmodel), "existing free normal fee does not settle extra rule to zero") + # These are schema/display examples only, never assigned to production data. + for attribute: StringName in [&"appetite", &"relaxation", &"sexual", &"gambling"]: + var named: Dictionary = FacilityPricing.pending_service_readmodel(264, attribute) + _expect(named[&"service_attribute"] == attribute and named[&"service_attribute"] is StringName and _is_pending(named), "known service label alone cannot resolve a customer match: %s" % attribute) + var after: Dictionary = _runtime.create_snapshot() + _expect(var_to_bytes(after) == before_bytes, "report/getter and caller mutations preserve full typed snapshot order and bytes") + _expect(SimulationDigest.compute(after) == before_digest, "observation preserves values including RNG, IDs and all saved domain events") + + +func _test_normal_ui() -> void: + _main.call("_activate_management_mode", &"business") + _panel.select_facility(_room_id) + await process_frame + await process_frame + _expect(_panel.visible and _panel.price_option.item_count == 5 and not _panel.price_option.disabled, "normal business UI retains five existing price policies") + var text := _panel.quote_label.text + _expect(text.contains("通常料金") and text.count("設定後 264G") == 1 and text.count("現在 264G") == 1 and text.count("標準 220G") == 1, "current, draft and base normal fees are each shown once") + _expect(text.contains("サービス属性:未設定") and text.contains("欲求一致加算:未設定"), "UI identifies both unset extra-rule parts separately from normal charge") + _expect(not text.contains("加算0G") and not text.contains("利用不可") and not text.contains("resolved"), "pending UI neither invents zero nor blocks use nor exposes implementation terms") + _expect(_panel.quote_label.autowrap_mode == TextServer.AUTOWRAP_WORD_SMART, "additional status wraps in normal narrow business panel") + var before := var_to_bytes(_runtime.create_snapshot()) + var owned := _panel.get_report() + var row := _row(owned, _room_id) + (row[&"service_pricing"] as Dictionary).clear() + _panel.refresh() + _expect(_is_pending(_row(_panel.get_report(), _room_id)[&"service_pricing"]), "panel client-owned report is defensive at nested pricing data") + for layer in [0, 1]: + _main.call("_set_layer", layer) + _panel.refresh() + _expect(_panel.quote_label.text == text, "palette/layer refresh keeps current normal fee and pending rule unchanged") + _main.call("_set_layer", 0) + _panel.select_facility(_reception_id) + var reception_row := _row(_panel.get_report(), _reception_id) + _expect(reception_row[&"category"] == &"guest_service" and reception_row[&"price"] == 0 and not bool(reception_row[&"can_price"]), "actual reception is a zero-normal-price guest service") + _expect(_panel.price_option.disabled and _panel.quote_label.text.count("通常の利用料金はありません(現在 0G)。") == 1 and _panel.quote_label.text.contains("サービス属性:未設定") and _panel.quote_label.text.contains("欲求一致加算:未設定"), "normal reception UI separates free normal fee from unset addition") + _expect(not _panel.quote_label.text.contains("この施設に利用料金はありません。") and not _panel.quote_label.text.contains("加算0G") and not _panel.business_open_button.disabled, "reception neither claims complete no-fee nor blocks ordinary admission") + _expect(_is_pending(reception_row[&"service_pricing"]) and reception_row[&"service_pricing"][&"service_attribute"] == null, "reception keeps unknown assessment/attribute/amount owned and unresolved") + _panel.select_facility(_warehouse_id) + _expect(_panel.price_option.disabled and _panel.quote_label.text == "この施設に利用料金はありません。" and not _panel.quote_label.text.contains("欲求一致加算"), "non-customer warehouse is not presented as a pending customer charge") + _expect(_panel.business_open_button.text == "営業する" and _panel.effect_label.text == "営業・休業を設定できます。" and _panel.explanation_label.text.contains("補給"), "warehouse keeps actual supply controls and explanation") + _panel.select_facility(_cleaning_id) + _expect(_panel.price_option.disabled and _panel.quote_label.text == "この施設に利用料金はありません。" and not _panel.quote_label.text.contains("欲求一致加算"), "paid cleaning base is not presented as a pending customer charge") + _expect(_panel.business_open_button.text == "清掃業務を有効にする" and _panel.facility_status_label.text.contains("顧客利用なし") and _panel.effect_label.text == "清掃業務の有効・無効を設定できます。", "cleaning retains staff-work meaning instead of customer admission") + _panel.select_facility(_room_id) + _expect(var_to_bytes(_runtime.create_snapshot()) == before, "customer and non-customer business selection/refresh changes no simulation snapshot") + + +func _test_frozen_service_price() -> void: + _expect(_runtime.submit_command(&"register_guest_wallet", {&"guest_id": &"pending_rule_payer", &"gold": 1000}).is_success(), "fund controlled normal service payer") + var queued: CommandResult = _runtime.submit_command(&"create_service_request", {&"request_id": &"pending_rule_stay", &"facility_id": _room_id, &"guest_ids": [&"pending_rule_payer"], &"service_type": &"lodging"}) + _expect(queued.is_success(), "unset extra rule does not stop ordinary service acceptance") + if not queued.is_success(): + return + var request: ServiceRequest = _runtime.facility_service.get_request(&"pending_rule_stay") + _expect(request.quoted_price == 264, "accepted request freezes existing current normal fee") + var queue_save: Dictionary = _runtime.create_snapshot() + var changed: CommandResult = _runtime.submit_command(&"set_facility_policy", {&"facility_id": _room_id, &"patch": {&"price_mode": &"expensive"}}) + _expect(changed.is_success() and _runtime.get_facility_business_price(_room()) == 396, "new normal estimate follows existing policy then Anri rounding") + _expect(_runtime.get_facility_service_pricing_readmodel(_room())[&"normal_price"] == 396 and request.quoted_price == 264, "new readmodel never rewrites an already queued quotation") + _expect(_runtime.restore_snapshot(queue_save), "queued quote and placement restore through actual Runtime validation") + request = _runtime.facility_service.get_request(&"pending_rule_stay") + _expect(request != null and request.quoted_price == 264 and _is_pending(_runtime.get_facility_service_pricing_readmodel(_room())), "loading retains queued normal price without resolving unknown surcharge") + _clock.advance_one_minute() + var active: Array = _runtime.facility_service.get_active_services(_room_id) + _expect(active.size() == 1 and active[0].price_snapshot == 264, "normal service starts with frozen accepted price") + if active.is_empty(): + return + var service_id: StringName = active[0].service_id + var started: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(started), "started service restores while extra rule remains pending") + _expect(_runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": &"heroine_anri", &"facility_id": &"", &"template_id": &"DAY"}).is_success(), "unassign Anri after accepted service has started") + _expect(_runtime.get_facility_service_pricing_readmodel(_room())[&"normal_price"] == 220 and _runtime.facility_service.get_service(service_id).price_snapshot == 264, "new normal fee reverts but loaded service price stays frozen") + _clock.advance_minutes(721) + _expect(_runtime.facility_service.get_service(service_id).state == ServiceInstance.State.COMPLETED, "ordinary paid service completes with unknown extra rule") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:pending_rule_payer") == 736, "customer pays frozen264 exactly once") + var revenue := 0 + for entry: LedgerEntry in _runtime.economy_service.get_entries(): + if entry.source_entity_id == _room_id and entry.category == &"SERVICE_SALE": + revenue += entry.amount + _expect(revenue == 264 and bool(_runtime.economy_service.validate_ledger()[&"ok"]), "facility receives exactly customer's264 with balanced ledger") + + +func _is_pending(value: Dictionary) -> bool: + return value.has(&"resolved") and value[&"resolved"] is bool and value[&"resolved"] == false and value.has(&"deficient_attributes") and value[&"deficient_attributes"] == null and value.has(&"matches") and value[&"matches"] == null and value.has(&"surcharge") and value[&"surcharge"] == null and value.has(&"total") and value[&"total"] == null + + +func _room() -> FacilityInstance: + return _runtime.facility_service.get_instance(_room_id) + + +func _row(report: Dictionary, id: StringName) -> Dictionary: + for row: Dictionary in report.get(&"facilities", []): + if StringName(row.get(&"facility_id", &"")) == id: + return row + return {} + + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) + + +func _finish() -> void: + if is_instance_valid(_main): + _main.queue_free() + print("Pending service pricing report: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) diff --git a/tests/mvp/test_pending_service_pricing_report.gd.uid b/tests/mvp/test_pending_service_pricing_report.gd.uid new file mode 100644 index 0000000..cc11e6c --- /dev/null +++ b/tests/mvp/test_pending_service_pricing_report.gd.uid @@ -0,0 +1 @@ +uid://d16wjlyo3kb3d