Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
This commit is contained in:
+296
-26
@@ -22,7 +22,9 @@ const WAGE_EXPENSE_ACCOUNT := &"WAGE_EXPENSE"
|
||||
const RUNTIME_SNAPSHOT_VERSION := 14
|
||||
const AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION := 14
|
||||
const EVENT_THREAT_RUNTIME_SNAPSHOT_VERSION := 13
|
||||
const AutoPurchasePlansType := preload("res://scripts/supply/supply_auto_purchase_plans.gd")
|
||||
const MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION := 15
|
||||
const PurchaseQuoteV2 := preload("res://scripts/supply/supply_purchase_quote_v2.gd")
|
||||
const AutoPurchasePlansType := preload("res://scripts/supply/supply_auto_purchase_quote_plans.gd")
|
||||
const CLEANING_RUNTIME_SNAPSHOT_VERSION := 12
|
||||
const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11
|
||||
const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd")
|
||||
@@ -292,6 +294,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
|
||||
_executors.clear()
|
||||
register_command_executor(&"change_resources", Callable(self, "_execute_change_resources"))
|
||||
register_command_executor(&"spend_resources", Callable(self, "_execute_spend_resources"))
|
||||
register_command_executor(&"repay_loan_principal", Callable(self, "_execute_repay_loan_principal"))
|
||||
register_command_executor(&"build_facility", Callable(self, "_execute_build_facility"))
|
||||
register_command_executor(&"cancel_construction", Callable(self, "_execute_cancel_construction"))
|
||||
register_command_executor(&"demolish_facility", Callable(self, "_execute_demolish_facility"))
|
||||
@@ -559,6 +562,8 @@ func create_snapshot() -> Dictionary:
|
||||
&"facility_infrastructure": facility_infrastructure_projector.create_snapshot(),
|
||||
},
|
||||
}
|
||||
if PurchaseQuoteV2.has_history(owned_systems[&"runtime"]):
|
||||
owned_systems[&"runtime"][&"runtime_schema_version"] = MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION
|
||||
# Every component above returns detached containers. Transfer this fresh
|
||||
# tree into the envelope instead of deep-copying all component snapshots a
|
||||
# second time. Keep generic capture's caller-owned-extra contract unchanged.
|
||||
@@ -616,8 +621,12 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
|
||||
CLEANING_RUNTIME_SNAPSHOT_VERSION,
|
||||
EVENT_THREAT_RUNTIME_SNAPSHOT_VERSION,
|
||||
RUNTIME_SNAPSHOT_VERSION,
|
||||
MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION,
|
||||
]:
|
||||
return false
|
||||
var has_purchase_v2_history := PurchaseQuoteV2.has_history(runtime_data)
|
||||
if (runtime_schema_version == MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION) != has_purchase_v2_history:
|
||||
return false
|
||||
if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION \
|
||||
and not _legacy_operations_history_is_absent(runtime_data):
|
||||
return false
|
||||
@@ -840,6 +849,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
|
||||
var restored_economy := EconomyService.new()
|
||||
if not restored_economy.restore_snapshot(runtime_data[&"economy"]):
|
||||
return false
|
||||
if not _loan_principal_ledger_is_valid(restored_economy):
|
||||
return false
|
||||
if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION:
|
||||
if not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT) \
|
||||
and not restored_economy.register_account(WAGE_EXPENSE_ACCOUNT):
|
||||
@@ -1221,6 +1232,9 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
|
||||
return false
|
||||
if restored_kitchen.current_minute != restored_runtime_domain_minute or not restored_kitchen.validate_crosslinks():
|
||||
return false
|
||||
if runtime_schema_version == MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION \
|
||||
and not PurchaseQuoteV2.valid_history(runtime_data, restored_economy):
|
||||
return false
|
||||
var restored_auto_purchase := AutoPurchasePlansType.new()
|
||||
if runtime_schema_version >= AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION:
|
||||
if not restored_auto_purchase.restore_snapshot(runtime_data[&"auto_purchase_plans"], restored_facilities, restored_economy, restored_runtime_domain_minute):
|
||||
@@ -2561,6 +2575,8 @@ func _build_operations_daily_financials(
|
||||
) -> Dictionary:
|
||||
if period_start < 0 or period_end_exclusive <= period_start or new_wages_total < 0:
|
||||
return {&"ok": false, &"reason": &"DAILY_FINANCIAL_PERIOD_INVALID"}
|
||||
if not _loan_principal_ledger_is_valid(economy_service):
|
||||
return {&"ok": false, &"reason": &"INVALID_LOAN_PRINCIPAL_LEDGER"}
|
||||
var category_totals := {
|
||||
&"revenue": 0,
|
||||
&"refunds": 0,
|
||||
@@ -2590,6 +2606,8 @@ func _build_operations_daily_financials(
|
||||
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
||||
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
||||
&"MAINTENANCE_ACCRUAL": &"maintenance",
|
||||
&"LOAN_PRINCIPAL_BORROWED": &"financing_principal_inflows",
|
||||
&"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows",
|
||||
}
|
||||
var operating_refund_total := 0
|
||||
for entry: LedgerEntry in economy_service.get_entries():
|
||||
@@ -2609,10 +2627,10 @@ func _build_operations_daily_financials(
|
||||
&"transaction_id": entry.transaction_id,
|
||||
}
|
||||
var category: StringName = category_mapping[entry.category]
|
||||
category_totals[category] = int(category_totals[category]) + entry.amount
|
||||
category_totals[category] = int(category_totals.get(category, 0)) + entry.amount
|
||||
if not entry.source_entity_id.is_empty() \
|
||||
and (facility_service.has_instance(entry.source_entity_id) or entry.category == &"MAINTENANCE_ACCRUAL") \
|
||||
and category != &"construction_cash_flow":
|
||||
and category not in [&"construction_cash_flow", &"financing_principal_inflows", &"financing_principal_outflows"]:
|
||||
if not facility_rows.has(entry.source_entity_id):
|
||||
facility_rows[entry.source_entity_id] = {
|
||||
&"revenue": 0, &"expenses": 0, &"net_profit": 0,
|
||||
@@ -3502,6 +3520,108 @@ func _operations_payroll_crosslinks_are_valid(
|
||||
return true
|
||||
|
||||
|
||||
## Existing liabilities only. This projection does not issue financing or infer terms.
|
||||
func get_loan_principal_report() -> Dictionary:
|
||||
if not _initialized or economy_service == null or _state == null:
|
||||
return {&"available": false, &"loans": []}
|
||||
var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT)
|
||||
var pending: Dictionary = {}
|
||||
for command: SimulationCommand in command_queue.get_pending_commands():
|
||||
if command.command_type != &"repay_loan_principal":
|
||||
continue
|
||||
var id: Variant = command.payload.get(&"loan_id", null)
|
||||
if typeof(id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
continue
|
||||
var key := StringName(id)
|
||||
if not pending.has(key): pending[key] = []
|
||||
pending[key].append(command.command_id)
|
||||
var loans: Array[Dictionary] = []
|
||||
for account: Dictionary in economy_service.get_accounts():
|
||||
var id := String(account[&"account_id"])
|
||||
if not id.begins_with("LOAN_LIABILITY:"):
|
||||
continue
|
||||
var loan := StringName(id.trim_prefix("LOAN_LIABILITY:"))
|
||||
loans.append({&"loan_id": loan, &"outstanding_principal": -economy_service.get_balance(StringName(id)), &"pending_command_ids": (pending.get(loan, []) as Array).duplicate()})
|
||||
loans.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"loan_id"]) < String(right[&"loan_id"]))
|
||||
var valid: bool = _state.get_resource(&"gold") == cash and (loans.is_empty() or _loan_principal_ledger_is_valid(economy_service))
|
||||
var ended: bool = _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal()
|
||||
return {&"available": true, &"ledger_valid": valid, &"terminal": ended, &"cash": cash, &"next_command_id": _next_command_id, &"loans": loans}
|
||||
|
||||
|
||||
## Atomic principal payment against an already attested loan liability.
|
||||
## Loan issuance, rates, due dates and bank eligibility belong to the bank
|
||||
## producer; this API does not create or approve any financing contract.
|
||||
func repay_loan_principal(
|
||||
loan_id: StringName,
|
||||
amount: Variant,
|
||||
idempotency_key: StringName
|
||||
) -> CommandResult:
|
||||
if not _initialized or economy_service == null or _state == null:
|
||||
return CommandResult.failed(CommandResult.Code.EXECUTOR_FAILED, 0, "Simulation runtime is not initialized.")
|
||||
if _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal():
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Repayment is unavailable after the game ends.")
|
||||
if loan_id.is_empty() or idempotency_key.is_empty() or typeof(amount) != TYPE_INT \
|
||||
or amount <= 0 or amount > OperationsCoordinator.MAX_FINANCIAL_AMOUNT:
|
||||
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, 0, "A loan id, positive integer principal and idempotency key are required.")
|
||||
if not economy_service.get_transaction_id_for_idempotency_key(idempotency_key).is_empty():
|
||||
return CommandResult.failed(CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, 0, "The principal payment key has already been posted.")
|
||||
if not _loan_principal_ledger_is_valid(economy_service):
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "The principal ledger is inconsistent.")
|
||||
var liability := StringName("LOAN_LIABILITY:" + String(loan_id))
|
||||
if not economy_service.has_account(liability):
|
||||
return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "The loan liability does not exist.")
|
||||
var remaining := -economy_service.get_balance(liability)
|
||||
var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT)
|
||||
if amount > remaining or amount > cash or _state.get_resource(&"gold") != cash:
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Principal payment exceeds the outstanding principal or available Gold, or its mirror is inconsistent.")
|
||||
# The single balanced posting is atomic. Defer receipts until the Gold
|
||||
# mirror is synchronized, also respecting any enclosing effect scope.
|
||||
economy_service.begin_notification_scope()
|
||||
var result := economy_service.transfer(
|
||||
PLAYER_CASH_ACCOUNT, liability, amount, _runtime_event_minute(),
|
||||
&"LOAN_PRINCIPAL_REPAID", loan_id, idempotency_key
|
||||
)
|
||||
if result.is_success():
|
||||
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
||||
result.details[&"loan_id"] = loan_id
|
||||
result.details[&"paid_principal"] = amount
|
||||
result.details[&"outstanding_principal"] = remaining - amount
|
||||
economy_service.end_notification_scope()
|
||||
return result
|
||||
|
||||
|
||||
func _loan_principal_ledger_is_valid(economy: EconomyService) -> bool:
|
||||
if economy == null:
|
||||
return false
|
||||
var outstanding: Dictionary = {}
|
||||
for entry: LedgerEntry in economy.get_entries():
|
||||
if entry.category not in [&"LOAN_PRINCIPAL_BORROWED", &"LOAN_PRINCIPAL_REPAID"]:
|
||||
continue
|
||||
if entry.source_entity_id.is_empty():
|
||||
return false
|
||||
var liability := StringName("LOAN_LIABILITY:" + String(entry.source_entity_id))
|
||||
if not economy.has_account(liability) or not economy.account_allows_negative(liability):
|
||||
return false
|
||||
var remaining := int(outstanding.get(liability, 0))
|
||||
if entry.category == &"LOAN_PRINCIPAL_BORROWED":
|
||||
if entry.debit_account != PLAYER_CASH_ACCOUNT or entry.credit_account != liability \
|
||||
or entry.amount > OperationsCoordinator.MAX_FINANCIAL_AMOUNT - remaining:
|
||||
return false
|
||||
remaining += entry.amount
|
||||
else:
|
||||
if entry.debit_account != liability or entry.credit_account != PLAYER_CASH_ACCOUNT \
|
||||
or entry.amount > remaining:
|
||||
return false
|
||||
remaining -= entry.amount
|
||||
outstanding[liability] = remaining
|
||||
for account: Dictionary in economy.get_accounts():
|
||||
var id: StringName = account[&"account_id"]
|
||||
if String(id).begins_with("LOAN_LIABILITY:"):
|
||||
if not economy.account_allows_negative(id) or economy.get_balance(id) != -int(outstanding.get(id, 0)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _operations_expected_category_totals(
|
||||
economy: EconomyService,
|
||||
period_start: int,
|
||||
@@ -3511,6 +3631,8 @@ func _operations_expected_category_totals(
|
||||
if economy == null or period_start < 0 or period_end_exclusive <= period_start \
|
||||
or new_wages_total < 0:
|
||||
return null
|
||||
if not _loan_principal_ledger_is_valid(economy):
|
||||
return null
|
||||
var totals := {
|
||||
&"revenue": 0, &"refunds": 0, &"cost_of_goods": 0,
|
||||
&"wages": new_wages_total, &"maintenance": 0, &"utilities": 0,
|
||||
@@ -3531,6 +3653,8 @@ func _operations_expected_category_totals(
|
||||
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
||||
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
||||
&"MAINTENANCE_ACCRUAL": &"maintenance",
|
||||
&"LOAN_PRINCIPAL_BORROWED": &"financing_principal_inflows",
|
||||
&"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows",
|
||||
}
|
||||
var operating_refunds := 0
|
||||
for entry: LedgerEntry in economy.get_entries():
|
||||
@@ -3545,7 +3669,7 @@ func _operations_expected_category_totals(
|
||||
if not mapping.has(entry.category):
|
||||
return null
|
||||
var category: StringName = mapping[entry.category]
|
||||
totals[category] = int(totals[category]) + entry.amount
|
||||
totals[category] = int(totals.get(category, 0)) + entry.amount
|
||||
var offset := mini(operating_refunds, int(totals[&"cost_of_goods"]))
|
||||
totals[&"cost_of_goods"] = int(totals[&"cost_of_goods"]) - offset
|
||||
totals[&"revenue"] = int(totals[&"revenue"]) + operating_refunds - offset
|
||||
@@ -4438,6 +4562,23 @@ func _perform_checkpoint_save(
|
||||
&"message": "Simulation runtime is not initialized.",
|
||||
}
|
||||
var started_at_us := Time.get_ticks_usec()
|
||||
# A rejected Path projection is not a complete Runtime save. Refuse before
|
||||
# pausing or touching formal/tmp/backup files, and keep the refusal observable
|
||||
# without publishing a persisted event into the live snapshot.
|
||||
if path_service.create_snapshot().is_empty():
|
||||
var refused: Dictionary = {
|
||||
&"ok": false, &"code": &"INVALID_SNAPSHOT",
|
||||
&"message": "Incomplete Path snapshot; original save preserved.",
|
||||
&"operation": &"autosave" if use_autosave else &"save",
|
||||
&"save_path": save_path, &"reason": reason, &"request_id": request_id,
|
||||
&"checkpoint_absolute_minute": _clock.absolute_minute,
|
||||
&"runtime_duration_us": maxi(int(Time.get_ticks_usec() - started_at_us), 0),
|
||||
&"clock_pause_restored": true, &"formal_save_preserved": true,
|
||||
&"live_state_preserved": true,
|
||||
}
|
||||
latest_save_operation_result = _save_event_payload(refused)
|
||||
save_operation_completed.emit(refused.duplicate(true))
|
||||
return refused
|
||||
var previous_paused := _clock.paused
|
||||
_clock.set_paused(true)
|
||||
var snapshot := create_snapshot()
|
||||
@@ -5241,6 +5382,7 @@ func _configure_relationship_event_boundaries() -> void:
|
||||
Callable(self, "_on_relationship_threshold_candidate")
|
||||
)
|
||||
event_service.configure_relationship_service(relationship_service)
|
||||
event_service.configure_candidate_eligibility_guard(Callable(self, "_event_candidate_placement_permitted"))
|
||||
event_service.configure_callbacks(
|
||||
Callable(self, "_event_actor_exists"),
|
||||
Callable(self, "_execute_event_action_boundary"),
|
||||
@@ -5251,6 +5393,32 @@ func _configure_relationship_event_boundaries() -> void:
|
||||
Callable(self, "_event_path_exists")
|
||||
)
|
||||
|
||||
## Original 6.2/6.4: Tomari's placement prevents floor trouble. This does not
|
||||
## choose a joining date, probability, defense score, or narrative content.
|
||||
func is_floor_trouble_protected(floor_y: int) -> bool:
|
||||
var tomari := staff_service.get_staff(&"heroine_tomari")
|
||||
if tomari == null or tomari.employment_kind != StaffRuntime.EmploymentKind.UNIQUE:
|
||||
return false
|
||||
var assignment := staff_service.get_work_assignment(tomari.staff_id)
|
||||
var facility := facility_service.get_instance(StringName(assignment.get(&"facility_id", &"")))
|
||||
return facility != null and facility.origin.y == floor_y
|
||||
|
||||
|
||||
func _event_candidate_placement_permitted(definition: EventDefinition, context: Dictionary) -> bool:
|
||||
if definition.category != &"TROUBLE":
|
||||
return true
|
||||
# A business trouble identifies its existing target facility. Missing or
|
||||
# conflicting location data cannot silently bypass placement protection.
|
||||
var target: Variant = context.get(&"facility_id", definition.location_rule.get(&"facility_id", null))
|
||||
if typeof(target) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
var declared: Variant = definition.location_rule.get(&"facility_id", target)
|
||||
if typeof(declared) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(declared) != StringName(target):
|
||||
return false
|
||||
var facility := facility_service.get_instance(StringName(target))
|
||||
return facility != null and not is_floor_trouble_protected(facility.origin.y)
|
||||
|
||||
|
||||
func _connect_relationship_signals() -> void:
|
||||
if relationship_service == null:
|
||||
return
|
||||
@@ -5267,6 +5435,8 @@ func _connect_event_signals() -> void:
|
||||
event_service.event_finished.connect(Callable(self, "_on_event_finished"))
|
||||
event_service.event_cancelled.connect(Callable(self, "_on_event_cancelled"))
|
||||
event_service.choice_resolved.connect(Callable(self, "_on_event_choice_resolved"))
|
||||
event_service.effect_transaction_started.connect(Callable(self, "_on_event_effect_transaction_started"))
|
||||
event_service.effect_transaction_finished.connect(Callable(self, "_on_event_effect_transaction_finished"))
|
||||
|
||||
func _initialize_s5_authority() -> void:
|
||||
authority_service = AuthorityService.new()
|
||||
@@ -5646,6 +5816,20 @@ func _execute_change_resources(command: SimulationCommand) -> CommandResult:
|
||||
&"faith_lifetime": int(progression_service.metrics[&"faith_lifetime"]),
|
||||
})
|
||||
|
||||
# Repay an existing attested liability through the ordinary command queue.
|
||||
# No rate, issuance, eligibility or due-date values are supplied here.
|
||||
func _execute_repay_loan_principal(command: SimulationCommand) -> CommandResult:
|
||||
var loan_id: Variant = command.payload.get(&"loan_id", null)
|
||||
if typeof(loan_id) not in [TYPE_STRING, TYPE_STRING_NAME] or String(loan_id).is_empty():
|
||||
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "loan_id must be a nonempty String or StringName.")
|
||||
var result := repay_loan_principal(
|
||||
StringName(loan_id), command.payload.get(&"amount", null), command.idempotency_key
|
||||
)
|
||||
result.command_id = command.command_id
|
||||
result.target_id = StringName(loan_id)
|
||||
return result
|
||||
|
||||
|
||||
func _execute_spend_resources(command: SimulationCommand) -> CommandResult:
|
||||
var cost: Variant = command.payload.get(&"cost", command.payload.get("cost", null))
|
||||
if not cost is Dictionary:
|
||||
@@ -6428,6 +6612,17 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
|
||||
_runtime_event_minute()
|
||||
)
|
||||
|
||||
func _staff_role_matches_facility(role_id: StringName, definition: FacilityDefinition) -> bool:
|
||||
if definition.required_roles.is_empty() or role_id in definition.required_roles:
|
||||
return true
|
||||
# Existing public-bath data uses a legacy job ID; the paid roster exposes
|
||||
# the canonical attendant. The other aliases likewise retain saved job IDs.
|
||||
if definition.id == &"public_bath" and role_id == &"attendant" and &"bath_attendant" in definition.required_roles:
|
||||
return true
|
||||
if definition.id == &"massage_room" and role_id == &"masseur" and &"therapist" in definition.required_roles:
|
||||
return true
|
||||
return definition.id == &"security_station" and role_id == &"guard" and &"security" in definition.required_roles
|
||||
|
||||
func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandResult:
|
||||
for key: StringName in [&"staff_id", &"facility_id", &"template_id"]:
|
||||
if typeof(command.payload.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
@@ -6456,7 +6651,7 @@ func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandRe
|
||||
var definition := definition_registry.get_facility(facility.definition_id)
|
||||
if definition == null or definition.staff_max <= 0:
|
||||
return _staff_assignment_failure(command.command_id, &"FACILITY_UNSTAFFED", "This facility does not accept Staff assignments.")
|
||||
if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles and not _is_anri_manager(staff):
|
||||
if not _staff_role_matches_facility(staff.role_id, definition) and not _is_anri_manager(staff):
|
||||
return _staff_assignment_failure(command.command_id, &"ROLE_MISMATCH", "Staff role does not match this facility.")
|
||||
if _staff_assignment_count(facility_id, template_id, staff_id) >= definition.staff_max:
|
||||
return _staff_assignment_failure(command.command_id, &"FACILITY_FULL", "This shift has reached the facility Staff limit.")
|
||||
@@ -7193,6 +7388,8 @@ func _execute_create_service_request(command: SimulationCommand) -> CommandResul
|
||||
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"})
|
||||
if instance.requires_operating_kitchen() and not bool(facility_service.get_required_kitchen_report(facility_id).get(&"operating", false)):
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "This dining facility requires an operating BACK kitchen.", {&"reason": &"FACILITY_CLOSED", &"dependency_reason": facility_service.get_required_kitchen_report(facility_id).get(&"reason", &"")})
|
||||
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) \
|
||||
@@ -7253,7 +7450,7 @@ func _execute_create_service_request(command: SimulationCommand) -> CommandResul
|
||||
if guest_service.has_guest(guest_id):
|
||||
runtime_guests.append(guest_id)
|
||||
if runtime_guests.is_empty():
|
||||
enqueue_result = facility_service.enqueue_request(request, _runtime_event_minute())
|
||||
enqueue_result = facility_service.enqueue_request(request, _runtime_event_minute(), not (_operations_enabled and _active_command_minute == _clock.absolute_minute + 1))
|
||||
else:
|
||||
var first_guest := runtime_guests[0]
|
||||
var queue_result := guest_service.enter_queue(
|
||||
@@ -7263,7 +7460,7 @@ func _execute_create_service_request(command: SimulationCommand) -> CommandResul
|
||||
_runtime_event_minute(),
|
||||
int(options.get(&"initial_patience", options.get("initial_patience", -1))),
|
||||
func(_guest_id: StringName, _facility_id: StringName, _request_id: StringName) -> CommandResult:
|
||||
return facility_service.enqueue_request(request, _runtime_event_minute())
|
||||
return facility_service.enqueue_request(request, _runtime_event_minute(), not (_operations_enabled and _active_command_minute == _clock.absolute_minute + 1))
|
||||
)
|
||||
if bool(queue_result.get(&"ok", false)):
|
||||
for guest_index in range(1, runtime_guests.size()):
|
||||
@@ -7598,7 +7795,8 @@ func _capture_infrastructure_demolition_state() -> Dictionary:
|
||||
|
||||
func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool:
|
||||
for key: StringName in [
|
||||
&"game_state", &"economy", &"world", &"path", &"elevators", &"legacy_stairs", &"construction",
|
||||
&"game_state", &"economy", &"facility_upkeep", &"upkeep_settlement",
|
||||
&"world", &"path", &"elevators", &"legacy_stairs", &"construction",
|
||||
&"excavation", &"spatial_subslots", &"route_projection",
|
||||
&"facility_infrastructure", &"facilities", &"facility_cleanliness", &"mana", &"movement_credits",
|
||||
&"domain_events", &"id_generator",
|
||||
@@ -7607,6 +7805,8 @@ func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool:
|
||||
return false
|
||||
var restored: bool = bool(_state.restore_snapshot(snapshot[&"game_state"]))
|
||||
restored = economy_service.restore_snapshot(snapshot[&"economy"]) and restored
|
||||
restored = facility_upkeep.restore_snapshot(snapshot[&"facility_upkeep"]) and restored
|
||||
restored = upkeep_settlement.restore_snapshot(snapshot[&"upkeep_settlement"], economy_service) and restored
|
||||
restored = world_service.restore_snapshot(snapshot[&"world"]) and restored
|
||||
restored = spatial_subslot_service.restore_snapshot(snapshot[&"spatial_subslots"]) and restored
|
||||
var route_snapshot: Dictionary = snapshot[&"route_projection"]
|
||||
@@ -7645,6 +7845,8 @@ func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool:
|
||||
func _infrastructure_demolition_state_matches(snapshot: Dictionary) -> bool:
|
||||
return _strict_variants_equal(_state.create_snapshot(), snapshot.get(&"game_state", {})) \
|
||||
and _strict_variants_equal(economy_service.create_snapshot(), snapshot.get(&"economy", {})) \
|
||||
and _strict_variants_equal(facility_upkeep.create_snapshot(), snapshot.get(&"facility_upkeep", {})) \
|
||||
and _strict_variants_equal(upkeep_settlement.create_snapshot(), snapshot.get(&"upkeep_settlement", {})) \
|
||||
and _strict_variants_equal(world_service.create_snapshot(), snapshot.get(&"world", {})) \
|
||||
and _strict_variants_equal(path_service.create_snapshot(), snapshot.get(&"path", {})) \
|
||||
and _strict_variants_equal(elevators.create_snapshot(), snapshot.get(&"elevators", {})) \
|
||||
@@ -9020,6 +9222,10 @@ func _project_guest_candidates(guest_id: StringName) -> Array:
|
||||
var candidates: Array = []
|
||||
if guest == null:
|
||||
return candidates
|
||||
var query_goals: Array = []
|
||||
for instance: FacilityInstance in facility_service.get_instances():
|
||||
query_goals.append_array(_facility_access_candidates(instance))
|
||||
path_service.begin_guest_path_query_batch(guest.current_cell, query_goals)
|
||||
for facility: FacilityInstance in facility_service.get_instances():
|
||||
var definition := definition_registry.get_facility(facility.definition_id)
|
||||
if definition == null or &"staff_only" in definition.tags:
|
||||
@@ -9058,6 +9264,7 @@ func _project_guest_candidates(guest_id: StringName) -> Array:
|
||||
&"graph_revision": path_service.graph_revision,
|
||||
},
|
||||
})
|
||||
path_service.end_guest_path_query_batch()
|
||||
return candidates
|
||||
|
||||
func _facility_access_candidates(facility: FacilityInstance) -> Array:
|
||||
@@ -9089,7 +9296,7 @@ func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) ->
|
||||
var path := path_service.find_path(start, candidate)
|
||||
if path.is_empty():
|
||||
continue
|
||||
if best_path.is_empty() or path_service.calculate_path_cost(path) < path_service.calculate_path_cost(best_path):
|
||||
if best_path.is_empty() or path_service.calculate_guest_batch_path_cost(start, candidate, path) < path_service.calculate_guest_batch_path_cost(start, best_cell, best_path):
|
||||
best_path.assign(path)
|
||||
best_cell = candidate
|
||||
return {&"reachable": not best_path.is_empty(), &"path": best_path, &"target_cell": best_cell}
|
||||
@@ -9689,7 +9896,11 @@ func _execute_event_action_boundary(
|
||||
token[&"defense"] = defense_service.create_snapshot()
|
||||
var result := _apply_event_action(action, context)
|
||||
if not bool(result.get(&"ok", false)):
|
||||
_restore_event_action_token(token)
|
||||
var rollback := _restore_event_action_token(token)
|
||||
if not bool(rollback.get(&"ok", false)):
|
||||
result[&"fatal"] = true
|
||||
result[&"rollback"] = rollback
|
||||
result[&"error"] = "Event Action failed and rollback failed: %s; %s" % [result.get(&"error", ""), rollback.get(&"error", "")]
|
||||
return result
|
||||
result[&"token"] = token
|
||||
return result
|
||||
@@ -9886,8 +10097,19 @@ func _reserve_event_cost(cost: Dictionary, context: Dictionary) -> Dictionary:
|
||||
normalized[resource_id] = amount
|
||||
var economy_before := economy_service.create_snapshot()
|
||||
var state_before: Dictionary = _state.create_snapshot()
|
||||
var ids_before := id_generator.create_snapshot()
|
||||
var instance_id := StringName(context.get(&"event_instance_id", ""))
|
||||
var reservation_id: StringName = &""
|
||||
for amount: int in normalized.values():
|
||||
if amount > 0:
|
||||
if id_generator.get_counter(&"event_cost") >= StableIdGenerator.MAX_COUNTER:
|
||||
return {&"ok": false, &"error": "Event cost reservation IDs are exhausted."}
|
||||
reservation_id = id_generator.next_id(&"event_cost")
|
||||
break
|
||||
var escrow_account := StringName("EVENT_ESCROW:%s" % instance_id)
|
||||
var reservation := {&"event_instance_id": instance_id, &"cost": normalized, &"escrow_account": escrow_account}
|
||||
if not reservation_id.is_empty():
|
||||
reservation[&"reservation_id"] = reservation_id
|
||||
var gold := int(normalized.get(&"gold", 0))
|
||||
if gold > 0:
|
||||
if not economy_service.has_account(escrow_account):
|
||||
@@ -9895,23 +10117,21 @@ func _reserve_event_cost(cost: Dictionary, context: Dictionary) -> Dictionary:
|
||||
var transfer := economy_service.transfer(
|
||||
PLAYER_CASH_ACCOUNT, escrow_account, gold, _runtime_event_minute(),
|
||||
&"EVENT_COST_RESERVED", instance_id,
|
||||
StringName("event.cost.reserve.%s" % instance_id)
|
||||
_event_cost_ledger_key(reservation, &"reserve")
|
||||
)
|
||||
if not transfer.is_success():
|
||||
economy_service.restore_snapshot(economy_before)
|
||||
_state.restore_snapshot(state_before)
|
||||
id_generator.restore_snapshot(ids_before)
|
||||
return {&"ok": false, &"error": transfer.message}
|
||||
var other_costs := normalized.duplicate(true)
|
||||
other_costs.erase(&"gold")
|
||||
if not other_costs.is_empty() and not _state.spend(other_costs):
|
||||
economy_service.restore_snapshot(economy_before)
|
||||
_state.restore_snapshot(state_before)
|
||||
id_generator.restore_snapshot(ids_before)
|
||||
return {&"ok": false, &"error": "Event Choice resources are unavailable."}
|
||||
return {&"ok": true, &"token": {
|
||||
&"event_instance_id": instance_id,
|
||||
&"cost": normalized,
|
||||
&"escrow_account": escrow_account,
|
||||
}}
|
||||
return {&"ok": true, &"token": reservation}
|
||||
|
||||
func _release_event_cost(token: Variant, _context: Dictionary) -> Dictionary:
|
||||
if token is Dictionary and token.is_empty():
|
||||
@@ -9926,7 +10146,7 @@ func _release_event_cost(token: Variant, _context: Dictionary) -> Dictionary:
|
||||
var released := economy_service.transfer(
|
||||
escrow_account, PLAYER_CASH_ACCOUNT, gold, _runtime_event_minute(),
|
||||
&"EVENT_COST_RELEASED", instance_id,
|
||||
StringName("event.cost.release.%s" % instance_id)
|
||||
_event_cost_ledger_key(token, &"release")
|
||||
)
|
||||
if not released.is_success():
|
||||
return {&"ok": false, &"error": released.message}
|
||||
@@ -9949,10 +10169,17 @@ func _commit_event_cost(token: Variant, _context: Dictionary) -> Dictionary:
|
||||
var committed := economy_service.transfer(
|
||||
StringName(token.get(&"escrow_account", "")), OPERATING_EXPENSE_ACCOUNT,
|
||||
gold, _runtime_event_minute(), &"EVENT_COST_COMMITTED", instance_id,
|
||||
StringName("event.cost.commit.%s" % instance_id)
|
||||
_event_cost_ledger_key(token, &"commit")
|
||||
)
|
||||
return {&"ok": committed.is_success(), &"error": committed.message}
|
||||
|
||||
func _event_cost_ledger_key(token: Dictionary, operation: StringName) -> StringName:
|
||||
var reservation_id := StringName(token.get(&"reservation_id", ""))
|
||||
if not reservation_id.is_empty():
|
||||
return StringName("event.reserved_cost.%s.%s" % [operation, reservation_id])
|
||||
# Existing pending save tokens keep the original ledger operation identity.
|
||||
return StringName("event.cost.%s.%s" % [operation, token.get(&"event_instance_id", "")])
|
||||
|
||||
func _on_relationship_threshold_candidate(candidate: Dictionary) -> void:
|
||||
var candidate_id := StringName(candidate.get(&"candidate_id", ""))
|
||||
if candidate_id.is_empty() or event_service.get_instance(candidate_id) != null:
|
||||
@@ -10096,7 +10323,7 @@ func _validate_infrastructure_crosslinks(
|
||||
or (require_minute_alignment \
|
||||
and excavation.current_minute != construction.current_minute) \
|
||||
or not _same_vector3i_set(
|
||||
adapter.get_base_walkable_cells(), world.get_walkable_cells()
|
||||
adapter.get_base_walkable_cells(false), world.get_walkable_cells(false)
|
||||
) \
|
||||
or not _same_vector3i_set(
|
||||
adapter.get_projected_route_cells(), spatial.get_route_cells()
|
||||
@@ -10181,7 +10408,7 @@ func _validate_runtime_crosslinks(
|
||||
var assigned_staff := staff.get_staff(StringName(assignment[&"staff_id"]))
|
||||
var definition := definition_registry.get_facility(assigned_facility.definition_id)
|
||||
if assigned_staff == null or definition == null or definition.staff_max <= 0 \
|
||||
or (not definition.required_roles.is_empty() and assigned_staff.role_id not in definition.required_roles and not _is_anri_manager(assigned_staff)):
|
||||
or (not _staff_role_matches_facility(assigned_staff.role_id, definition) and not _is_anri_manager(assigned_staff)):
|
||||
return false
|
||||
var queued_links: Dictionary = {}
|
||||
var service_links: Dictionary = {}
|
||||
@@ -11482,6 +11709,22 @@ func _complete_service_transaction(
|
||||
request: ServiceRequest,
|
||||
facility: FacilityInstance,
|
||||
absolute_minute: int
|
||||
) -> Variant:
|
||||
# A sale is only observable after its inventory, rewards and cleanliness
|
||||
# effects commit. Rollback removes provisional receipts before scope exit.
|
||||
economy_service.begin_notification_scope()
|
||||
var result: Variant = _apply_service_transaction(service, request, facility, absolute_minute)
|
||||
if result is bool and result:
|
||||
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
||||
economy_service.end_notification_scope()
|
||||
return result
|
||||
|
||||
|
||||
func _apply_service_transaction(
|
||||
service: ServiceInstance,
|
||||
request: ServiceRequest,
|
||||
facility: FacilityInstance,
|
||||
absolute_minute: int
|
||||
) -> Variant:
|
||||
for guest_id: StringName in service.guest_ids:
|
||||
var guest := guest_service.get_guest(guest_id)
|
||||
@@ -12022,6 +12265,18 @@ func _on_relationship_route_lock_changed(
|
||||
&"route_id": route_id,
|
||||
})
|
||||
|
||||
func _on_event_effect_transaction_started() -> void:
|
||||
economy_service.begin_notification_scope()
|
||||
|
||||
|
||||
func _on_event_effect_transaction_finished() -> void:
|
||||
# A nested action batch ends before the enclosing choice cost commits.
|
||||
# Keep the authoritative mirror current without releasing observer receipts.
|
||||
if economy_service.notification_scope_depth() == 1:
|
||||
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
||||
economy_service.end_notification_scope()
|
||||
|
||||
|
||||
func _on_event_enqueued(instance: EventInstance) -> void:
|
||||
_publish_actor_event(
|
||||
&"EVENT_ENQUEUED", &"event_service", instance.actor_ids,
|
||||
@@ -12043,7 +12298,8 @@ func _on_event_finished(instance: EventInstance) -> void:
|
||||
|
||||
func _on_event_cancelled(instance: EventInstance, reason: StringName) -> void:
|
||||
_consume_relationship_candidate_from_event(instance)
|
||||
if event_service.get_foreground_instance() == null and not event_service.pause_requested:
|
||||
# Placement rejection occurs before an Event starts and never owns a pause.
|
||||
if reason != &"PLACEMENT_PROTECTION" and event_service.get_foreground_instance() == null and not event_service.pause_requested:
|
||||
_clock.set_paused(false)
|
||||
_publish_actor_event(&"EVENT_CANCELLED", &"event_service", instance.actor_ids, {
|
||||
&"reason": reason,
|
||||
@@ -12241,10 +12497,13 @@ func get_business_management_report() -> Dictionary:
|
||||
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] = []
|
||||
var kitchens: Array[Dictionary] = []
|
||||
for facility: FacilityInstance in facility_service.get_instances():
|
||||
var definition := definition_registry.get_facility(facility.definition_id)
|
||||
if definition == null:
|
||||
continue
|
||||
if facility.definition_id == &"kitchen" and facility.origin.z == WorldService.LAYER_BACK:
|
||||
kitchens.append({&"facility_id": facility.instance_id, &"display_name": definition.display_name, &"origin": Vector2i(facility.origin.x, facility.origin.y), &"layer": facility.origin.z, &"operation_state": StringName(FacilityInstance.OperationState.keys()[facility.operation_state])})
|
||||
var amounts: Dictionary = totals.get(facility.instance_id, {})
|
||||
var base := _business_base_price(facility)
|
||||
var service_pricing := get_facility_service_pricing_readmodel(facility)
|
||||
@@ -12260,6 +12519,7 @@ func get_business_management_report() -> Dictionary:
|
||||
&"origin": Vector2i(facility.origin.x, facility.origin.y),
|
||||
&"layer": facility.origin.z,
|
||||
&"state": StringName(FacilityInstance.State.keys()[facility.state]),
|
||||
&"kitchen_dependency": facility_service.get_required_kitchen_report(facility.instance_id),
|
||||
&"business_open": facility.operator_open,
|
||||
&"price_mode": StringName(facility.policy_current.get(&"price_mode", &"standard")),
|
||||
&"base_price": base,
|
||||
@@ -12279,7 +12539,7 @@ func get_business_management_report() -> Dictionary:
|
||||
&"today_revenue": int(amounts.get(&"revenue", 0)),
|
||||
&"today_expenses": int(amounts.get(&"expenses", 0)),
|
||||
})
|
||||
return {&"minute": minute, &"modes": FacilityPricing.modes(), &"facilities": rows}
|
||||
return {&"minute": minute, &"modes": FacilityPricing.modes(), &"facilities": rows, &"kitchens": kitchens}
|
||||
|
||||
|
||||
func _guest_has_checked_in(guest: GuestRuntime) -> bool:
|
||||
@@ -12538,9 +12798,16 @@ func is_staff_in_transit(staff_id: StringName) -> bool:
|
||||
|
||||
func _estimated_actor_path_minutes(path: Array, access: Array[StringName]) -> int:
|
||||
for index: int in range(1, path.size()):
|
||||
if elevators.owns_connector(path_service.get_step_connector(path[index - 1], path[index], access)) \
|
||||
or _step_movement_credit(path_service, path[index - 1], path[index], access) > MOVEMENT_CREDIT_SCALE:
|
||||
return ceili(float(path_service.calculate_path_cost(path, access)) / PathService.ORTHOGONAL_MOVE_COST)
|
||||
var connector_id := path_service.get_step_connector(path[index - 1], path[index], access)
|
||||
if elevators.owns_connector(connector_id) \
|
||||
or _step_movement_credit_for_connector(path_service, path[index - 1], path[index], connector_id) > MOVEMENT_CREDIT_SCALE:
|
||||
var cost: int
|
||||
if access.is_empty() and path_service.get_script().resource_path == "res://scripts/world/path_service.gd" \
|
||||
and path[0] is Vector3i and path[path.size() - 1] is Vector3i:
|
||||
cost = path_service.calculate_guest_batch_path_cost(path[0], path[path.size() - 1], path)
|
||||
else:
|
||||
cost = path_service.calculate_path_cost(path, access)
|
||||
return ceili(float(cost) / PathService.ORTHOGONAL_MOVE_COST)
|
||||
return maxi(path.size() - 1, 0)
|
||||
|
||||
|
||||
@@ -12571,7 +12838,10 @@ func _prune_expired_staff_elevator_waits(minute: int) -> void:
|
||||
## one-step walking behavior, and elevator time is owned by the cabin service.
|
||||
static func _step_movement_credit(path: PathService, from: Vector3i, to: Vector3i,
|
||||
access: Array[StringName]) -> int:
|
||||
var connector_id := path.get_step_connector(from, to, access)
|
||||
return _step_movement_credit_for_connector(path,from,to,path.get_step_connector(from,to,access))
|
||||
|
||||
|
||||
static func _step_movement_credit_for_connector(path: PathService, from: Vector3i, to: Vector3i, connector_id: StringName) -> int:
|
||||
if String(connector_id).begins_with("infra.connector:") and from.x == to.x \
|
||||
and from.z == to.z and absi(from.y - to.y) == 1:
|
||||
var connector := path.get_connector(connector_id)
|
||||
|
||||
Reference in New Issue
Block a user