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.
This commit is contained in:
Takeshi
2026-10-01 02:43:39 +09:00
parent 5dde0c5ec2
commit 285528b7a3
5 changed files with 255 additions and 3 deletions
+12 -1
View File
@@ -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,
+14
View File
@@ -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,
+11 -2
View File
@@ -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))]
@@ -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)
@@ -0,0 +1 @@
uid://d16wjlyo3kb3d