Add business pricing and closure controls with compatible digest optimization

This commit is contained in:
Takeshi
2026-09-09 12:04:12 +09:00
parent 071e338e82
commit b3b82ef03e
35 changed files with 2203 additions and 59 deletions
+209
View File
@@ -0,0 +1,209 @@
extends SceneTree
var _checks := 0
var _failures := 0
var _runtime: Variant
var _clock: Variant
var _state: Variant
var _cafe_id: StringName = &""
var _serial := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("Business closure: real tick order, grouped queues and scheduled policies")
_test_last_patience_minute()
_test_group_preflight_and_retry()
_test_scheduled_closure_restore()
_test_outside_opening_hours_rejects_new_request()
_clear_fixture()
print("Business closure: %d checks, %d failures" % [_checks, _failures])
quit(1 if _failures else 0)
func _test_last_patience_minute() -> void:
if not _fixture(8101):
return
_queue([&"last_minute"], &"last.queue", 1)
var available := _available_meals()
_expect(_policy({&"business_open": false}).is_success(), "close while a guest has only one patience minute")
_clock.advance_minutes(1)
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"last_minute")
_expect(guest != null and guest.patience_remaining == 1, "actual tick releases closed queue before consuming its final patience minute")
_expect(guest.queue_request_id.is_empty(), "the guest no longer owns the closed request")
_expect(_runtime.facility_service.get_request(&"last.queue") == null, "closed queue request is removed")
_expect(_available_meals() == available + 1, "the finite meal reservation returns to cafeteria stock")
_expect(_closure_events(&"last_minute") == 1, "exactly one closure redecision event is published")
_expect(not _has_abandonment(&"last_minute"), "closure never publishes a patience-exhausted abandonment")
func _test_group_preflight_and_retry() -> void:
if not _fixture(8102):
return
_queue([&"group_a", &"group_b"], &"group.queue", 1)
_expect(_policy({&"business_open": false}).is_success(), "close a two-guest queued request")
var blocked_minute: int = _clock.absolute_minute + 1
var observations := {&"before": {}, &"after": {}, &"closure_callbacks": 0}
_runtime.domain_events.subscribe(&"GUEST_STATE_CHANGED", &"test.business.closure", func(event: SimulationDomainEvent):
if StringName(event.payload.get(&"reason", &"")) == &"FACILITY_CLOSED":
observations[&"closure_callbacks"] += 1
)
# Consume one member's budget immediately before the real queue phase. This
# reproduces a same-minute transition limit without moving any clock backwards.
_clock.register_tick_handler(_clock.TickPhase.FACILITY_QUEUES, &"aa.business.budget", func(minute: int):
if minute != blocked_minute:
return
var second: GuestRuntime = _runtime.guest_service.get_guest(&"group_b")
second.transition_tick = minute
second.transitions_this_tick = GuestService.MAX_TRANSITIONS_PER_TICK
observations[&"before"] = _runtime.create_snapshot()
)
_clock.register_tick_handler(_clock.TickPhase.FACILITY_QUEUES, &"zz.business.budget", func(minute: int):
if minute == blocked_minute:
observations[&"after"] = _runtime.create_snapshot()
)
var available := _available_meals()
_clock.advance_minutes(1)
_expect(not observations[&"before"].is_empty() and observations[&"before"] == observations[&"after"], "group budget failure leaves the entire queue-phase snapshot unchanged")
_expect(observations[&"closure_callbacks"] == 0, "no first-member state event escapes a rejected group release")
_expect(_runtime.facility_service.get_queue(_cafe_id).size() == 1 and _available_meals() == available, "deferred group retains one request and its exact stock reservation")
for id: StringName in [&"group_a", &"group_b"]:
var waiting: GuestRuntime = _runtime.guest_service.get_guest(id)
_expect(waiting.state == GuestRuntime.State.QUEUING and waiting.patience_remaining == 1, "deferred closed group preserves %s state and patience" % id)
_clock.advance_minutes(1)
_expect(observations[&"closure_callbacks"] == 2, "both members publish closure redecision on the next available minute")
_expect(_runtime.facility_service.get_queue(_cafe_id).is_empty() and _available_meals() == available + 1, "group retry releases its shared request and inventory once")
for id: StringName in [&"group_a", &"group_b"]:
var released: GuestRuntime = _runtime.guest_service.get_guest(id)
_expect(released != null and released.queue_request_id.is_empty() and released.patience_remaining == 1, "successful retry preserves %s patience" % id)
_expect(not _has_abandonment(id), "group retry never abandons %s through a patience failure" % id)
func _test_scheduled_closure_restore() -> void:
if not _fixture(8103):
return
_queue([&"scheduled"], &"scheduled.queue", 3)
var effective_at: int = _clock.absolute_minute + 1
_expect(_cmd(&"set_facility_policy", {
&"facility_id": _cafe_id, &"patch": {&"business_open": false}, &"effective_at": effective_at,
}).is_success(), "schedule closure at the next minute boundary")
_expect(_runtime.facility_service.get_instance(_cafe_id).operator_open, "a future closure does not change current operator intent")
var saved: Dictionary = _runtime.create_snapshot()
_clock.advance_minutes(1)
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"scheduled")
_expect(not _runtime.facility_service.get_instance(_cafe_id).operator_open, "scheduled closure applies in the facility phase")
_expect(guest != null and guest.patience_remaining == 3 and guest.queue_request_id.is_empty(), "scheduled closure releases its waiting guest before patience advances")
var completed: Dictionary = _runtime.create_snapshot()
_expect(_runtime.restore_snapshot(saved), "pending closure plus reserved guest request restores")
_clock.advance_minutes(1)
_expect(_runtime.create_snapshot() == completed, "restored scheduled closure reproduces the exact state and event history")
func _test_outside_opening_hours_rejects_new_request() -> void:
if not _fixture(8104):
return
_spawn(&"before_open")
_expect(_runtime.facility_service.set_opening_hours(_cafe_id, 600, 1_200, _clock.absolute_minute).is_success(), "set opening hours beginning after the current minute")
var stock: Dictionary = _runtime.inventory_service.create_snapshot()
var guests: Dictionary = _runtime.guest_service.create_snapshot()
var facilities: Dictionary = _runtime.facility_service.create_snapshot()
var result := _cmd(&"create_service_request", {&"facility_id": _cafe_id, &"request_id": &"outside.queue", &"guest_ids": [&"before_open"]})
_expect(not result.is_success() and result.details.get(&"reason", &"") == &"FACILITY_CLOSED", "outside-hours request is rejected as closed")
_expect(_runtime.inventory_service.create_snapshot() == stock, "outside-hours rejection does not reserve inventory")
_expect(_runtime.guest_service.create_snapshot() == guests and _runtime.facility_service.create_snapshot() == facilities, "outside-hours rejection cannot enqueue or change the guest")
func _fixture(seed_value: int) -> bool:
_clear_fixture()
_clock = load("res://autoload/game_clock.gd").new()
_state = load("res://autoload/game_state.gd").new()
_runtime = load("res://autoload/simulation_runtime.gd").new()
_clock.reset(1, 6, 0)
_state.reset()
var initialized: bool = _runtime.initialize(_clock, _state, seed_value).get(&"valid", false)
_expect(initialized, "closure runtime initializes")
if not initialized:
return false
_clock.set_paused(true)
for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)]]:
_expect(_cmd(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "closure fixture starts %s construction" % row[0])
_clock.advance_minutes(180)
_cafe_id = &""
for facility: FacilityInstance in _runtime.facility_service.get_instances():
if facility.definition_id == &"cafeteria":
_cafe_id = facility.instance_id
_expect(not _cafe_id.is_empty(), "cafeteria construction completes through ordinary ticks")
if _cafe_id.is_empty():
return false
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_cafe_id)
_expect(_cmd(&"add_inventory_stack", {&"owner_inventory_id": cafe.local_inventory_id, &"item_id": SupplyCatalog.BASIC_MEAL, &"quantity": 4}).is_success(), "finite meal fixture is available to reserve")
return true
func _queue(guest_ids: Array[StringName], request_id: StringName, patience: int) -> void:
for id: StringName in guest_ids:
_spawn(id)
_expect(_cmd(&"create_service_request", {
&"facility_id": _cafe_id, &"request_id": request_id, &"guest_ids": guest_ids,
&"options": {&"initial_patience": patience},
}).is_success(), "real guest actors own the queued meal reservation")
func _spawn(guest_id: StringName) -> void:
_expect(_cmd(&"spawn_guest", {
&"guest_id": guest_id, &"guest_type_id": &"traveler", &"gold": 200,
&"options": {&"current_cell": Vector3i.ZERO, &"planned_exit_at": _clock.absolute_minute + 240},
}).is_success(), "spawn closure guest %s" % guest_id)
_runtime.guest_service.transition_guest(guest_id, GuestRuntime.State.ENTERING, _clock.absolute_minute)
_runtime.guest_service.transition_guest(guest_id, GuestRuntime.State.DECIDING, _clock.absolute_minute)
func _closure_events(guest_id: StringName) -> int:
var count := 0
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
if event.entity_ids.has(guest_id) and StringName(event.payload.get(&"reason", &"")) == &"FACILITY_CLOSED":
count += 1
return count
func _has_abandonment(guest_id: StringName) -> bool:
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
if not event.entity_ids.has(guest_id):
continue
if event.event_type == &"GUEST_QUEUE_ABANDONED" or StringName(event.payload.get(&"reason", &"")) == &"PATIENCE_EXHAUSTED":
return true
return false
func _available_meals() -> int:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_cafe_id)
return _runtime.inventory_service.available_quantity(cafe.local_inventory_id, SupplyCatalog.BASIC_MEAL)
func _policy(patch: Dictionary) -> CommandResult:
return _cmd(&"set_facility_policy", {&"facility_id": _cafe_id, &"patch": patch})
func _cmd(type: StringName, payload: Dictionary) -> CommandResult:
_serial += 1
return _runtime.submit_command(type, payload, &"player", &"", 75, -1, StringName("business.closure.%d" % _serial))
func _expect(ok: bool, message: String) -> void:
_checks += 1
if not ok:
_failures += 1
push_error(message)
func _clear_fixture() -> void:
if _runtime != null:
_runtime.free()
_clock.free()
_state.free()
_runtime = null
_clock = null
_state = null
@@ -0,0 +1 @@
uid://dv11kpqhqkmtg
+160
View File
@@ -0,0 +1,160 @@
extends SceneTree
var _checks := 0
var _failures := 0
var _runtime: Variant
var _clock: Variant
var _state: Variant
var _ids: Dictionary = {}
var _serial := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_clock = load("res://autoload/game_clock.gd").new()
_state = load("res://autoload/game_state.gd").new()
_runtime = load("res://autoload/simulation_runtime.gd").new()
_clock.reset(1, 6, 0)
_state.reset()
_expect(_runtime.initialize(_clock, _state, 4405).get(&"valid", false), "runtime initializes")
_clock.set_paused(true)
for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)]]:
_expect(_cmd(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "build %s" % row[0])
_clock.advance_minutes(180)
_expect(_cmd(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-8, 2, 0)}).is_success(), "room builds on completed reception")
_clock.advance_minutes(60)
for facility: FacilityInstance in _runtime.facility_service.get_instances():
_ids[facility.definition_id] = facility.instance_id
_expect(_ids.size() == 3, "three real construction jobs complete")
if _ids.size() < 3:
_finish()
return
_catalog_and_report()
_immutable_sale()
_closed_queue()
_save_and_candidates()
_finish()
func _catalog_and_report() -> void:
var report: Dictionary = _runtime.get_business_management_report()
_expect(report[&"modes"].size() == 5 and report[&"facilities"].size() == 3, "five price tiers and completed facilities are exposed")
for row: Dictionary in report[&"facilities"]:
if row[&"facility_id"] == _ids[&"reception"]:
_expect(row[&"price"] == 0 and not row[&"can_price"], "reception has no direct charge")
elif row[&"facility_id"] == _ids[&"standard_room"]:
_expect(row[&"base_price"] == 120, "new room uses documented 120 G rate")
else:
_expect(row[&"base_price"] == 25, "new cafeteria uses documented 25 G rate")
var before: Dictionary = _runtime.create_snapshot()
report[&"facilities"][0][&"price"] = 99999
_expect(_runtime.create_snapshot() == before, "read model is detached from authority")
var reception: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"reception"])
reception.base_price = 20 # A legacy v7 facility keeps its stored fields.
_expect(_cmd(&"register_guest_wallet", {&"guest_id": &"checkin", &"gold": 50}).is_success(), "wallet for reception regression")
_expect(_cmd(&"create_service_request", {&"facility_id": reception.instance_id, &"request_id": &"free_checkin", &"guest_ids": [&"checkin"]}).is_success(), "legacy reception accepts new check-in")
_expect(_runtime.facility_service.get_request(&"free_checkin").quoted_price == 0, "new quotes at legacy reception are free")
func _immutable_sale() -> void:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(_cmd(&"set_facility_conditions", {&"facility_id": cafe.instance_id, &"conditions": {&"active_staff_ids": [&"cook", &"server"]}}).is_success(), "staff facility for settlement test")
_expect(_cmd(&"add_inventory_stack", {&"owner_inventory_id": cafe.local_inventory_id, &"item_id": SupplyCatalog.BASIC_MEAL, &"quantity": 3}).is_success(), "provide finite meal fixture")
_expect(_cmd(&"register_guest_wallet", {&"guest_id": &"payer", &"gold": 200}).is_success(), "payer wallet")
_expect(_policy(cafe.instance_id, {&"price_mode": &"expensive"}).is_success(), "set expensive tier")
_expect(_cmd(&"create_service_request", {&"facility_id": cafe.instance_id, &"request_id": &"meal.expensive", &"guest_ids": [&"payer"], &"options": {&"service_minutes": 3}}).is_success(), "quote an expensive meal")
_expect(_runtime.facility_service.get_request(&"meal.expensive").quoted_price == 38, "25 G at 150% rounds half up to 38 G")
_expect(_policy(cafe.instance_id, {&"price_mode": &"charitable"}).is_success(), "price changes before service starts")
_clock.advance_minutes(1)
var active: Array = _runtime.facility_service.get_active_services(cafe.instance_id)
_expect(active.size() == 1 and active[0].price_snapshot == 38, "queue retains old quote and policy")
var checkpoint: Dictionary = _runtime.create_snapshot()
var cash: int = _state.get_resource(&"gold")
_expect(_policy(cafe.instance_id, {&"business_open": false}).is_success(), "operator closes during meal")
var inventory: Dictionary = _runtime.inventory_service.create_snapshot()
_expect(not _cmd(&"create_service_request", {&"facility_id": cafe.instance_id, &"guest_ids": [&"payer"]}).is_success(), "closed business rejects new reservations")
_expect(_runtime.inventory_service.create_snapshot() == inventory, "rejected closed request consumes no stock")
_clock.advance_minutes(3)
_expect(_state.get_resource(&"gold") == cash + 38 and _runtime.economy_service.get_balance(&"GUEST_WALLET:payer") == 162, "closing still finishes and charges existing quote once")
_expect(_runtime.facility_service.get_active_services(cafe.instance_id).is_empty(), "closed facility finishes its existing service")
_expect(_runtime.restore_snapshot(checkpoint), "active quote and policy restore together")
_expect(_policy(cafe.instance_id, {&"business_open": false}).is_success(), "replay closure")
_clock.advance_minutes(3)
_expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:payer") == 162, "restored meal also settles once")
_expect(_runtime.economy_service.validate_ledger().get(&"ok", false), "settlement ledger reconciles")
var report: Dictionary = _runtime.get_business_management_report()
for row: Dictionary in report[&"facilities"]:
if row[&"facility_id"] == cafe.instance_id:
_expect(row[&"today_revenue"] == 38 and row[&"price"] == 15 and not row[&"business_open"], "report shows actual sale, future price, and closure separately")
func _closed_queue() -> void:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(_policy(cafe.instance_id, {&"business_open": true}).is_success(), "reopen")
_expect(_cmd(&"spawn_guest", {&"guest_id": &"queue_guest", &"guest_type_id": &"traveler", &"gold": 200, &"options": {&"current_cell": Vector3i.ZERO, &"planned_exit_at": _clock.absolute_minute + 240}}).is_success(), "spawn real guest actor")
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"queue_guest")
_runtime.guest_service.transition_guest(guest.guest_id, GuestRuntime.State.ENTERING, _clock.absolute_minute)
_runtime.guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, _clock.absolute_minute)
_expect(_cmd(&"create_service_request", {&"facility_id": cafe.instance_id, &"request_id": &"closing.queue", &"guest_ids": [guest.guest_id]}).is_success(), "guest owns queued meal reservation")
var patience: int = guest.patience_remaining
var available: int = _runtime.inventory_service.available_quantity(cafe.local_inventory_id, SupplyCatalog.BASIC_MEAL)
_expect(_policy(cafe.instance_id, {&"business_open": false}).is_success(), "close queued facility")
# Same-tick transition budget may already be exhausted; the release retries.
_runtime.call("_release_closed_business_queues", _clock.absolute_minute + 1)
guest = _runtime.guest_service.get_guest(&"queue_guest")
_expect(guest.queue_request_id.is_empty() and guest.state == GuestRuntime.State.DECIDING, "closure releases guest for a fresh decision")
_expect(guest.patience_remaining == patience, "closing does not fake a patience failure")
_expect(_runtime.facility_service.get_queue(cafe.instance_id).is_empty() and _runtime.inventory_service.available_quantity(cafe.local_inventory_id, SupplyCatalog.BASIC_MEAL) == available + 1, "queue and reserved stock release together")
# Align every service clock before save validation.
_clock.advance_minutes(1)
func _save_and_candidates() -> void:
var cafe: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(_policy(cafe.instance_id, {&"business_open": true, &"price_mode": &"standard"}).is_success(), "reopen at standard rate")
var guest: GuestRuntime = _runtime.guest_service.get_guest(&"queue_guest")
if guest != null:
var low_candidates: Array = _runtime.call("_project_guest_candidates", guest.guest_id)
var low: Dictionary = {}
for row: Dictionary in low_candidates:
if row[&"facility_id"] == cafe.instance_id: low = row
_policy(cafe.instance_id, {&"price_mode": &"exorbitant"})
var high: Dictionary = {}
for row: Dictionary in _runtime.call("_project_guest_candidates", guest.guest_id):
if row[&"facility_id"] == cafe.instance_id: high = row
_expect(not low.is_empty() and not high.is_empty() and low[&"estimated_total_gold"] == 25 and high[&"estimated_total_gold"] == 63, "guest candidate quotes track visible price tier")
if not low.is_empty() and not high.is_empty():
_expect(_runtime.guest_service.evaluate_candidate(guest.guest_id, low)[&"utility"] > _runtime.guest_service.evaluate_candidate(guest.guest_id, high)[&"utility"], "higher price lowers same guest's utility")
var service := ServiceInstance.new()
service.quality_snapshot = 55
service.policy_snapshot = {&"price_mode": &"charitable"}
var satisfied: int = _runtime.call("_service_satisfaction_sample", service, guest)
service.policy_snapshot = {&"price_mode": &"exorbitant"}
_expect(satisfied > int(_runtime.call("_service_satisfaction_sample", service, guest)), "price policy affects satisfaction sample")
var save_path := "user://tests/business_%d/policy" % OS.get_process_id()
_expect(_runtime.save_to_path(save_path).get(&"ok", false), "business policies save to an isolated disk slot")
var price_before: int = _runtime.get_business_management_report()[&"facilities"].size()
_expect(_runtime.load_from_path(save_path).get(&"ok", false), "business policies and guest ownership load")
_expect(_runtime.get_business_management_report()[&"facilities"].size() == price_before, "facility reports remain available after load")
cafe = _runtime.facility_service.get_instance(_ids[&"cafeteria"])
_expect(cafe.operator_open and cafe.policy_current[&"price_mode"] == &"exorbitant", "disk restore preserves open state and price mode")
var before: Dictionary = _runtime.facility_service.create_snapshot()
_expect(not _policy(cafe.instance_id, {&"price_mode": &"unknown", &"business_open": false}).is_success(), "invalid tier rejects entire combined policy")
_expect(_runtime.facility_service.create_snapshot() == before, "invalid patch cannot partially close business")
func _policy(id: StringName, patch: Dictionary) -> CommandResult:
return _cmd(&"set_facility_policy", {&"facility_id": id, &"patch": patch})
func _cmd(type: StringName, payload: Dictionary) -> CommandResult:
_serial += 1
return _runtime.submit_command(type, payload, &"player", &"", 75, -1, StringName("business.test.%d" % _serial))
func _expect(ok: bool, message: String) -> void:
_checks += 1
if not ok:
_failures += 1
push_error(message)
func _finish() -> void:
print("Business operations: %d checks, %d failures" % [_checks, _failures])
_runtime.free()
_clock.free()
_state.free()
quit(1 if _failures else 0)
@@ -0,0 +1 @@
uid://xnbsrxgxnvsx