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
+138 -3
View File
@@ -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)