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:
@@ -76,13 +76,14 @@ func drain_ready(current_tick: int) -> Array[SimulationCommand]:
|
||||
break
|
||||
_pending.pop_front()
|
||||
_in_flight[command.command_id] = command
|
||||
ready.append(command)
|
||||
ready.append(command.copy())
|
||||
return ready
|
||||
|
||||
|
||||
func execute_ready(current_tick: int, executor: Callable) -> Array[CommandResult]:
|
||||
var results: Array[CommandResult] = []
|
||||
for command in drain_ready(current_tick):
|
||||
var accepted_command_id := command.command_id
|
||||
var result: CommandResult
|
||||
if not executor.is_valid():
|
||||
result = CommandResult.failed(
|
||||
@@ -103,10 +104,8 @@ func execute_ready(current_tick: int, executor: Callable) -> Array[CommandResult
|
||||
"Executor did not return a successful CommandResult."
|
||||
)
|
||||
|
||||
if result.command_id == 0:
|
||||
result.command_id = command.command_id
|
||||
complete(command.command_id, result)
|
||||
results.append(result.copy())
|
||||
var completed_result := complete(accepted_command_id, result)
|
||||
results.append(completed_result)
|
||||
return results
|
||||
|
||||
|
||||
@@ -164,6 +163,14 @@ func processed_count() -> int:
|
||||
return _processed_results.size()
|
||||
|
||||
|
||||
## Read only pending commands, without serializing the processed history.
|
||||
func get_pending_commands() -> Array[SimulationCommand]:
|
||||
var result: Array[SimulationCommand] = []
|
||||
for command: SimulationCommand in _pending:
|
||||
result.append(command.copy())
|
||||
return result
|
||||
|
||||
|
||||
func get_accepted_command_metadata() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var command_ids: Array = _accepted_metadata.keys()
|
||||
|
||||
@@ -26,6 +26,9 @@ const DEFAULT_BUDGETS := {
|
||||
var sample_capacity := DEFAULT_SAMPLE_CAPACITY
|
||||
var _budgets: Dictionary = {}
|
||||
var _samples: Dictionary = {}
|
||||
# Derived diagnostic index; never part of game Save snapshots.
|
||||
var _ordered_samples: Dictionary = {}
|
||||
var _ordered_sample_bytes: Dictionary = {}
|
||||
var _totals: Dictionary = {}
|
||||
var _sample_counts: Dictionary = {}
|
||||
|
||||
@@ -59,12 +62,22 @@ func record_sample(metric_id: StringName, duration_us: int) -> Dictionary:
|
||||
if not _samples.has(metric_id):
|
||||
_samples[metric_id] = []
|
||||
var values: Array = _samples[metric_id]
|
||||
var use_index: bool = get_script().resource_path == "res://scripts/core/performance_budget.gd" and _integer_window(values)
|
||||
var ordered: Array[int] = []
|
||||
if use_index: ordered = _validated_sample_order(metric_id, values)
|
||||
values.append(duration_us)
|
||||
if use_index: ordered.insert(ordered.bsearch(duration_us), duration_us)
|
||||
if values.size() > sample_capacity:
|
||||
values.pop_front()
|
||||
var removed: Variant = values.pop_front()
|
||||
if use_index: ordered.remove_at(ordered.bsearch(int(removed)))
|
||||
if use_index:
|
||||
_ordered_sample_bytes[metric_id] = var_to_bytes(values)
|
||||
else:
|
||||
_ordered_samples.erase(metric_id)
|
||||
_ordered_sample_bytes.erase(metric_id)
|
||||
_totals[metric_id] = int(_totals.get(metric_id, 0)) + duration_us
|
||||
_sample_counts[metric_id] = int(_sample_counts.get(metric_id, 0)) + 1
|
||||
var report := get_report(metric_id)
|
||||
var report: Dictionary = _indexed_report(metric_id, values, ordered) if use_index else get_report(metric_id)
|
||||
if bool(report.get(&"budget_exceeded", false)):
|
||||
budget_exceeded.emit(metric_id, report.duplicate(true))
|
||||
return {&"ok": true, &"report": report}
|
||||
@@ -92,15 +105,25 @@ func record_simulation_frame(duration_us: int, speed: int, ticks_run: int) -> Di
|
||||
|
||||
|
||||
func get_report(metric_id: StringName) -> Dictionary:
|
||||
var values: Array = _samples.get(metric_id, [])
|
||||
var raw: Array = _samples.get(metric_id, [])
|
||||
if get_script().resource_path != "res://scripts/core/performance_budget.gd" or not _integer_window(raw):
|
||||
return SimulationPerformanceBudget._legacy_report(self, metric_id)
|
||||
var ordered: Array[int] = []
|
||||
return _indexed_report(metric_id, raw, ordered, true)
|
||||
|
||||
|
||||
func _indexed_report(metric_id: StringName, values: Array, ordered: Array[int], validate_order: bool = false) -> Dictionary:
|
||||
var count := values.size()
|
||||
var lifetime_count := int(_sample_counts.get(metric_id, 0))
|
||||
var lifetime_total := int(_totals.get(metric_id, 0))
|
||||
var average_us := lifetime_total / lifetime_count if lifetime_count > 0 else 0
|
||||
var maximum_us := 0
|
||||
for raw_value: Variant in values:
|
||||
maximum_us = maxi(maximum_us, int(raw_value))
|
||||
var p95_us := _percentile(values, 95)
|
||||
if validate_order: ordered = _validated_sample_order(metric_id, values)
|
||||
var maximum_us := maxi(0, ordered[-1]) if not ordered.is_empty() else 0
|
||||
var p95_us := 0
|
||||
if not ordered.is_empty():
|
||||
var rank_numerator := 95 * ordered.size()
|
||||
var index := maxi((rank_numerator + 99) / 100 - 1, 0)
|
||||
p95_us = ordered[mini(index, ordered.size() - 1)]
|
||||
var budget: Dictionary = _budgets.get(metric_id, {})
|
||||
var aggregation := int(budget.get(&"aggregation", Aggregation.P95))
|
||||
var measured_us := _aggregated_value(aggregation, average_us, p95_us, maximum_us)
|
||||
@@ -139,10 +162,14 @@ func get_all_reports() -> Array[Dictionary]:
|
||||
func clear(metric_id: StringName = &"") -> void:
|
||||
if metric_id.is_empty():
|
||||
_samples.clear()
|
||||
_ordered_samples.clear()
|
||||
_ordered_sample_bytes.clear()
|
||||
_totals.clear()
|
||||
_sample_counts.clear()
|
||||
return
|
||||
_samples.erase(metric_id)
|
||||
_ordered_samples.erase(metric_id)
|
||||
_ordered_sample_bytes.erase(metric_id)
|
||||
_totals.erase(metric_id)
|
||||
_sample_counts.erase(metric_id)
|
||||
|
||||
@@ -172,3 +199,55 @@ func _percentile(raw_values: Array, percentile: int) -> int:
|
||||
var rank_numerator := clampi(percentile, 0, 100) * values.size()
|
||||
var index := maxi((rank_numerator + 99) / 100 - 1, 0)
|
||||
return values[mini(index, values.size() - 1)]
|
||||
|
||||
|
||||
static func _integer_window(values: Array) -> bool:
|
||||
for raw_value: Variant in values:
|
||||
if typeof(raw_value) != TYPE_INT: return false
|
||||
return true
|
||||
|
||||
|
||||
func _validated_sample_order(metric_id: StringName, values: Array) -> Array[int]:
|
||||
# No retained state for unknown metrics; raw dictionary removal also drops its index.
|
||||
if not _samples.has(metric_id):
|
||||
_ordered_samples.erase(metric_id)
|
||||
_ordered_sample_bytes.erase(metric_id)
|
||||
return []
|
||||
# Complete immutable raw bytes detect in-place/replaced windows, not just length.
|
||||
var current_bytes := var_to_bytes(values)
|
||||
if not _ordered_samples.has(metric_id) or _ordered_sample_bytes.get(metric_id, PackedByteArray()) != current_bytes:
|
||||
var ordered: Array[int] = []
|
||||
for raw_value: Variant in values: ordered.append(int(raw_value))
|
||||
ordered.sort()
|
||||
_ordered_samples[metric_id] = ordered
|
||||
_ordered_sample_bytes[metric_id] = current_bytes
|
||||
return _ordered_samples[metric_id]
|
||||
|
||||
|
||||
static func _legacy_report(owner: SimulationPerformanceBudget, metric_id: StringName) -> Dictionary:
|
||||
var values: Array = owner._samples.get(metric_id, [])
|
||||
var count := values.size()
|
||||
var lifetime_count := int(owner._sample_counts.get(metric_id, 0))
|
||||
var lifetime_total := int(owner._totals.get(metric_id, 0))
|
||||
var average_us := lifetime_total / lifetime_count if lifetime_count > 0 else 0
|
||||
var maximum_us := 0
|
||||
for raw_value: Variant in values:
|
||||
maximum_us = maxi(maximum_us, int(raw_value))
|
||||
var p95_us := owner._percentile(values, 95)
|
||||
var budget: Dictionary = owner._budgets.get(metric_id, {})
|
||||
var aggregation := int(budget.get(&"aggregation", Aggregation.P95))
|
||||
var measured_us := owner._aggregated_value(aggregation, average_us, p95_us, maximum_us)
|
||||
var limit_us := int(budget.get(&"limit_us", 0))
|
||||
return {
|
||||
&"metric_id": metric_id,
|
||||
&"window_sample_count": count,
|
||||
&"lifetime_sample_count": lifetime_count,
|
||||
&"average_us": average_us,
|
||||
&"p95_us": p95_us,
|
||||
&"maximum_us": maximum_us,
|
||||
&"aggregation": aggregation,
|
||||
&"measured_us": measured_us,
|
||||
&"limit_us": limit_us,
|
||||
&"budget_exceeded": limit_us > 0 and measured_us > limit_us,
|
||||
}
|
||||
|
||||
|
||||
+205
-11
@@ -2,6 +2,10 @@ class_name SaveService
|
||||
extends RefCounted
|
||||
|
||||
const FORMAT_VERSION := 1
|
||||
const MIMI_PURCHASE_FORMAT_VERSION := 2
|
||||
const SUPPORTED_CURRENT_FORMAT := -2
|
||||
const PurchaseQuoteV2 := preload("res://scripts/supply/supply_purchase_quote_v2.gd")
|
||||
const PurchaseQuotePlansV2 := preload("res://scripts/supply/supply_auto_purchase_quote_plans.gd")
|
||||
const MAGIC := "TTWB"
|
||||
const WORLD_FILE := "world.bin"
|
||||
const MANIFEST_FILE := "manifest.json"
|
||||
@@ -27,12 +31,15 @@ static func save_snapshot(save_path: String, snapshot: Dictionary, metadata := {
|
||||
var resolved := _resolve_safe_save_path(save_path)
|
||||
if not resolved[&"ok"]:
|
||||
return _complete_timed_operation(resolved, &"save", started_at_us)
|
||||
var selected_format := _snapshot_package_format(snapshot)
|
||||
if selected_format < 0:
|
||||
return _complete_timed_operation(_failure(&"INVALID_SNAPSHOT", "unsupported purchase Runtime/domain"), &"save", started_at_us)
|
||||
var result := _save_snapshot_absolute(
|
||||
resolved[&"path"],
|
||||
save_path,
|
||||
snapshot,
|
||||
metadata,
|
||||
FORMAT_VERSION,
|
||||
selected_format,
|
||||
resolved[&"path"],
|
||||
resolved[&"path"]
|
||||
)
|
||||
@@ -47,7 +54,7 @@ static func load_snapshot(save_path: String) -> Dictionary:
|
||||
var reconcile := _reconcile_interrupted_formal_save(resolved[&"path"])
|
||||
if not reconcile[&"ok"]:
|
||||
return _complete_timed_operation(reconcile, &"load", started_at_us)
|
||||
var result := _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION)
|
||||
var result := _load_snapshot_absolute(resolved[&"path"], SUPPORTED_CURRENT_FORMAT)
|
||||
result[&"interrupted_save_reconciled"] = bool(reconcile.get(&"reconciled", false))
|
||||
return _complete_timed_operation(result, &"load", started_at_us)
|
||||
|
||||
@@ -60,7 +67,7 @@ static func inspect_snapshot(save_path: String) -> Dictionary:
|
||||
var resolved := _resolve_safe_save_path(save_path)
|
||||
var result: Dictionary = resolved
|
||||
if resolved[&"ok"]:
|
||||
result = _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION)
|
||||
result = _load_snapshot_absolute(resolved[&"path"], SUPPORTED_CURRENT_FORMAT)
|
||||
result[&"read_only"] = true
|
||||
return _complete_timed_operation(result, &"load", started_at_us)
|
||||
|
||||
@@ -98,6 +105,9 @@ static func save_autosave(
|
||||
if not resolved[&"ok"]:
|
||||
return _complete_timed_operation(resolved, &"save", started_at_us)
|
||||
var target_path: String = resolved[&"path"]
|
||||
var selected_format := _snapshot_package_format(snapshot)
|
||||
if selected_format < 0:
|
||||
return _complete_timed_operation(_failure(&"INVALID_SNAPSHOT", "unsupported purchase Runtime/domain"), &"save", started_at_us)
|
||||
var pending_path := target_path + ".autosave.pending"
|
||||
var reconcile := _reconcile_autosave_rotation(target_path, pending_path, generation_count)
|
||||
if not reconcile[&"ok"]:
|
||||
@@ -137,7 +147,7 @@ static func save_autosave(
|
||||
get_autosave_path(save_path, 0),
|
||||
snapshot,
|
||||
candidate_metadata,
|
||||
FORMAT_VERSION,
|
||||
selected_format,
|
||||
target_path + AUTOSAVE_SUFFIX + "0",
|
||||
target_path,
|
||||
false
|
||||
@@ -201,7 +211,11 @@ static func inspect_recovery_candidates(
|
||||
var formal_reconcile := _reconcile_interrupted_formal_save(target_path)
|
||||
if not formal_reconcile[&"ok"]:
|
||||
return formal_reconcile
|
||||
var migration_reconcile := _reconcile_interrupted_migration(target_path)
|
||||
var migration_reconcile: Dictionary = (
|
||||
formal_reconcile[&"migration_reconciliation"]
|
||||
if formal_reconcile.has(&"migration_reconciliation")
|
||||
else _reconcile_interrupted_migration(target_path)
|
||||
)
|
||||
if not migration_reconcile[&"ok"]:
|
||||
return migration_reconcile
|
||||
var autosave_reconcile := _reconcile_autosave_rotation(
|
||||
@@ -235,7 +249,7 @@ static func inspect_recovery_candidates(
|
||||
var current_schema_valid := false
|
||||
var current_format := false
|
||||
if package_valid:
|
||||
current_format = int(loaded[&"manifest"].get("format_version", -1)) == FORMAT_VERSION
|
||||
current_format = _package_domain_matches(loaded)
|
||||
current_schema_valid = bool(SimulationSnapshot.validate(loaded[&"snapshot"])[&"ok"])
|
||||
var loadable := package_valid and current_format and current_schema_valid
|
||||
var candidate_code: StringName = StringName(loaded[&"code"])
|
||||
@@ -359,7 +373,7 @@ static func _load_recovery_candidate_impl(
|
||||
candidate_path = target_path + AUTOSAVE_SUFFIX + str(generation)
|
||||
else:
|
||||
return _failure(&"UNKNOWN_RECOVERY_CANDIDATE", str(candidate_id))
|
||||
var result := _load_snapshot_absolute(candidate_path, FORMAT_VERSION)
|
||||
var result := _load_snapshot_absolute(candidate_path, SUPPORTED_CURRENT_FORMAT)
|
||||
if not result[&"ok"]:
|
||||
return result
|
||||
result[&"candidate_id"] = candidate_id
|
||||
@@ -396,7 +410,9 @@ static func replace_snapshot_with_backup(save_path: String, expected: Dictionary
|
||||
if not validation[&"ok"]:
|
||||
return _failure(&"MIGRATION_INVALID_SNAPSHOT", ", ".join(validation[&"errors"]))
|
||||
var started := Time.get_ticks_usec()
|
||||
return _complete_timed_operation(_migrate_save_impl(save_path, null, FORMAT_VERSION, expected, candidate), &"save", started)
|
||||
var selected_format := _snapshot_package_format(candidate)
|
||||
if selected_format < 0: return _failure(&"MIGRATION_INVALID_SNAPSHOT", "unsupported purchase Runtime/domain")
|
||||
return _complete_timed_operation(_migrate_save_impl(save_path, null, selected_format, expected, candidate), &"save", started)
|
||||
|
||||
|
||||
static func _migrate_save_impl(
|
||||
@@ -664,6 +680,8 @@ static func _save_snapshot_absolute(
|
||||
identity_source_path := "",
|
||||
use_transaction_marker := true
|
||||
) -> Dictionary:
|
||||
if _snapshot_package_format(snapshot) != format_version:
|
||||
return _failure(&"INVALID_SNAPSHOT", "package/domain format differs")
|
||||
var validation := SimulationSnapshot.validate(snapshot)
|
||||
if not validation[&"ok"]:
|
||||
return _failure(&"INVALID_SNAPSHOT", ", ".join(validation[&"errors"]))
|
||||
@@ -674,6 +692,9 @@ static func _save_snapshot_absolute(
|
||||
return _failure(&"UNSAFE_PATH", "save path contains a symbolic link")
|
||||
var interrupted_reconcile := {&"ok": true, &"reconciled": false, &"action": &"none"}
|
||||
if use_transaction_marker:
|
||||
var format_guard := _guard_ordinary_save_format(target_path, format_version)
|
||||
if not format_guard[&"ok"]:
|
||||
return format_guard
|
||||
interrupted_reconcile = _reconcile_interrupted_formal_save(target_path)
|
||||
if not interrupted_reconcile[&"ok"]:
|
||||
return interrupted_reconcile
|
||||
@@ -754,6 +775,85 @@ static func _save_snapshot_absolute(
|
||||
}
|
||||
|
||||
|
||||
## Ordinary Save must not act as an implicit physical-format migration.
|
||||
## This preflight only reads the formal package and the candidate which the
|
||||
## existing normal-save reconciliation rules could promote. Explicit migration
|
||||
## APIs and autosave rotation keep their own unchanged contracts.
|
||||
static func _guard_ordinary_save_format(target_path: String, requested_format: int) -> Dictionary:
|
||||
var formal_guard := _guard_identified_formal_format(target_path)
|
||||
if not formal_guard[&"ok"]:
|
||||
return formal_guard
|
||||
var marker_path := target_path + SAVE_TRANSACTION_SUFFIX
|
||||
var marker_exists := FileAccess.file_exists(marker_path) or DirAccess.dir_exists_absolute(marker_path)
|
||||
if marker_exists and not _formal_transaction_marker_is_valid(marker_path):
|
||||
return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path)
|
||||
var identified := _inspect_identified_save_formats(target_path)
|
||||
if not identified[&"ok"]:
|
||||
return identified
|
||||
for existing_format: int in identified[&"format_versions"]:
|
||||
if existing_format != requested_format:
|
||||
return _ordinary_format_migration_required(target_path, existing_format, requested_format)
|
||||
var target_status := _package_status(target_path)
|
||||
if target_status[&"current_loadable"] or (target_status[&"exists"] and not marker_exists):
|
||||
return {&"ok": true, &"code": &"OK"}
|
||||
if target_status[&"exists"] and not _looks_like_save_package(target_path):
|
||||
return _failure(&"FORMAL_RECOVERY_TARGET_CONFLICT", target_path)
|
||||
# Reconciliation chooses a verified .tmp first, then a verified .bak. A
|
||||
# missing/invalid formal does not make a retained old-format source a new slot.
|
||||
for candidate_path: String in [target_path + ".tmp", target_path + ".bak"]:
|
||||
var status := _package_status(candidate_path)
|
||||
if not status[&"current_loadable"]:
|
||||
continue
|
||||
var existing_format := int(status[&"format_version"])
|
||||
if existing_format != requested_format:
|
||||
return _ordinary_format_migration_required(candidate_path, existing_format, requested_format)
|
||||
break
|
||||
return {&"ok": true, &"code": &"OK"}
|
||||
|
||||
|
||||
## A corrupt format1 checksum/manifest still identifies a retained format when
|
||||
## either its bounded manifest number or fixed TTWB header says so. Never use a
|
||||
## schema-looking nested string or coerce a non-integral manifest value.
|
||||
static func _inspect_identified_save_formats(target_path: String) -> Dictionary:
|
||||
var versions: Array[int] = []
|
||||
var manifest_path := target_path.path_join(MANIFEST_FILE)
|
||||
if FileAccess.file_exists(manifest_path):
|
||||
if not _package_file_is_safe_for_read(manifest_path):
|
||||
return _failure(&"UNSAFE_PATH", manifest_path)
|
||||
var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
|
||||
if manifest_file == null:
|
||||
return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error()))
|
||||
if manifest_file.get_length() <= MAX_MANIFEST_BYTES:
|
||||
var parser := JSON.new()
|
||||
if parser.parse(manifest_file.get_as_text()) == OK and parser.data is Dictionary:
|
||||
var value: Variant = parser.data.get("format_version", null)
|
||||
if _is_integral_json_number(value, 0, 0xffffffff):
|
||||
versions.append(int(value))
|
||||
manifest_file = null
|
||||
var world_path := target_path.path_join(WORLD_FILE)
|
||||
if FileAccess.file_exists(world_path):
|
||||
if not _package_file_is_safe_for_read(world_path):
|
||||
return _failure(&"UNSAFE_PATH", world_path)
|
||||
var world_file := FileAccess.open(world_path, FileAccess.READ)
|
||||
if world_file == null:
|
||||
return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error()))
|
||||
if world_file.get_length() >= 8 and world_file.get_buffer(4).get_string_from_ascii() == MAGIC:
|
||||
var version := world_file.get_32()
|
||||
if version not in versions:
|
||||
versions.append(version)
|
||||
world_file = null
|
||||
return {&"ok": true, &"code": &"OK", &"format_versions": versions}
|
||||
|
||||
|
||||
static func _ordinary_format_migration_required(source_path: String, existing_format: int, requested_format: int) -> Dictionary:
|
||||
return _with_context(_failure(&"FORMAT_MIGRATION_REQUIRED", "%d -> %d requires explicit migration or a new slot" % [existing_format, requested_format]), {
|
||||
&"existing_format_version": existing_format,
|
||||
&"requested_format_version": requested_format,
|
||||
&"migration_source_path": source_path,
|
||||
&"formal_and_artifacts_preserved": true,
|
||||
})
|
||||
|
||||
|
||||
static func _write_package_directory(
|
||||
package_path: String,
|
||||
snapshot: Dictionary,
|
||||
@@ -761,6 +861,8 @@ static func _write_package_directory(
|
||||
format_version: int,
|
||||
logical_target_path := ""
|
||||
) -> Dictionary:
|
||||
if format_version in [FORMAT_VERSION, MIMI_PURCHASE_FORMAT_VERSION] and _snapshot_package_format(snapshot) != format_version:
|
||||
return _failure(&"INVALID_SNAPSHOT", "package/domain format differs")
|
||||
if not _operation_paths_are_safe([package_path]):
|
||||
return _failure(&"UNSAFE_PATH", "package path contains a symbolic link")
|
||||
if not _path_is_missing_or_package(package_path):
|
||||
@@ -812,6 +914,8 @@ static func _write_package_directory(
|
||||
&"checksum_world": checksum,
|
||||
&"uncompressed_size": raw_world.size(),
|
||||
}
|
||||
if format_version == MIMI_PURCHASE_FORMAT_VERSION:
|
||||
manifest[&"runtime_schema_version"] = 15
|
||||
for key: Variant in metadata:
|
||||
if key in [&"thumbnail_png", &"thumbnail_path", &"_thumbnail_png"]:
|
||||
continue
|
||||
@@ -839,7 +943,7 @@ static func _write_package_directory(
|
||||
return {&"ok": true, &"code": &"OK", &"manifest": manifest}
|
||||
|
||||
|
||||
static func _load_snapshot_absolute(
|
||||
static func _load_snapshot_absolute_v1(
|
||||
target_path: String,
|
||||
expected_format_version: int,
|
||||
validate_snapshot := true
|
||||
@@ -932,6 +1036,87 @@ static func _load_snapshot_absolute(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
## Format choice comes only from the decoded owned Runtime, never from a
|
||||
## recursively found schema-looking string or the duplicate manifest field.
|
||||
static func _snapshot_package_format(snapshot: Dictionary) -> int:
|
||||
var systems: Variant = snapshot.get(&"systems", {})
|
||||
if not systems is Dictionary: return FORMAT_VERSION # existing generic extra payload
|
||||
var runtime_data: Variant = systems.get(&"runtime", null)
|
||||
if runtime_data == null: return FORMAT_VERSION # existing generic envelope
|
||||
if not runtime_data is Dictionary: return FORMAT_VERSION # no owned Runtime authority
|
||||
var version: Variant = runtime_data.get(&"runtime_schema_version", 1)
|
||||
if typeof(version) != TYPE_INT: return -1
|
||||
if version >= 1 and version <= 14:
|
||||
return -1 if PurchaseQuoteV2.has_history(runtime_data) else FORMAT_VERSION
|
||||
if version != 15: return -1
|
||||
if not _canonical_owned_key(snapshot, &"systems") or not _canonical_owned_key(systems, &"runtime"): return -1
|
||||
var clock: Variant = snapshot.get(&"clock")
|
||||
if not clock is Dictionary or typeof(clock.get(&"absolute_minute")) != TYPE_INT: return -1
|
||||
return MIMI_PURCHASE_FORMAT_VERSION if PurchaseQuotePlansV2.valid_runtime_format(runtime_data, clock[&"absolute_minute"]) else -1
|
||||
|
||||
|
||||
static func _canonical_owned_key(owner: Dictionary, key: StringName) -> bool:
|
||||
for raw: Variant in owner:
|
||||
if raw == key: return typeof(raw) == TYPE_STRING_NAME
|
||||
return false
|
||||
|
||||
|
||||
static func _package_domain_matches(loaded: Dictionary) -> bool:
|
||||
if not bool(loaded.get(&"ok", false)): return false
|
||||
var manifest: Dictionary = loaded[&"manifest"]
|
||||
var format_version := int(manifest.get("format_version", -1))
|
||||
if format_version not in [FORMAT_VERSION, MIMI_PURCHASE_FORMAT_VERSION]: return false
|
||||
if format_version != _snapshot_package_format(loaded[&"snapshot"]): return false
|
||||
if format_version == MIMI_PURCHASE_FORMAT_VERSION:
|
||||
var duplicate_version: Variant = manifest.get("runtime_schema_version")
|
||||
return _is_integral_json_number(duplicate_version, 15, 15)
|
||||
return true
|
||||
|
||||
|
||||
static func _load_snapshot_absolute(target_path: String, expected_format_version: int,
|
||||
validate_snapshot := true) -> Dictionary:
|
||||
# -2 means either supported current format; -1 still decodes historical
|
||||
# physical packages for explicit migration/reporting. Positive verification
|
||||
# requests keep their exact original physical-format check.
|
||||
if expected_format_version == SUPPORTED_CURRENT_FORMAT:
|
||||
var guard := _guard_identified_formal_format(target_path)
|
||||
if not guard[&"ok"]: return guard
|
||||
var expected := -1 if expected_format_version == SUPPORTED_CURRENT_FORMAT else expected_format_version
|
||||
var loaded := _load_snapshot_absolute_v1(target_path, expected, validate_snapshot)
|
||||
if not loaded[&"ok"]: return loaded
|
||||
var version := int(loaded[&"manifest"].get("format_version", -1))
|
||||
if version in [FORMAT_VERSION, MIMI_PURCHASE_FORMAT_VERSION]:
|
||||
if not _package_domain_matches(loaded):
|
||||
return _with_context(_failure(&"UNSUPPORTED_FORMAT", "package/domain crosslink differs"), {&"formal_and_artifacts_preserved": true})
|
||||
elif expected_format_version == SUPPORTED_CURRENT_FORMAT:
|
||||
return _failure(&"UNSUPPORTED_FORMAT", str(version))
|
||||
return loaded
|
||||
|
||||
|
||||
static func _guard_identified_formal_format(target_path: String) -> Dictionary:
|
||||
var original := _guard_identified_formal_format_v1(target_path)
|
||||
if not original[&"ok"]:
|
||||
# A checksum-damaged future format remains protected by the original
|
||||
# prefix/manifest probe. Only one coherent, fully decoded known2 passes.
|
||||
if original[&"code"] != &"UNSUPPORTED_FORMAT" \
|
||||
or original.get(&"identified_format_version") != MIMI_PURCHASE_FORMAT_VERSION \
|
||||
or original.get(&"identified_format_versions") != [MIMI_PURCHASE_FORMAT_VERSION]: return original
|
||||
var known := _load_snapshot_absolute_v1(target_path, MIMI_PURCHASE_FORMAT_VERSION, true)
|
||||
return {&"ok": true, &"code": &"OK"} if _package_domain_matches(known) else original
|
||||
# Existing malformed/checksum-broken format1 recovery remains allowed. A
|
||||
# successfully decoded Runtime15 hidden in format1 may not be overwritten.
|
||||
var known := _load_snapshot_absolute_v1(target_path, FORMAT_VERSION, false)
|
||||
if known[&"ok"] and not _package_domain_matches(known):
|
||||
var invalid := _failure(&"UNSUPPORTED_FORMAT", "format1 contains reserved/unsupported Runtime history")
|
||||
invalid[&"identified_format_version"] = FORMAT_VERSION
|
||||
invalid[&"identified_format_versions"] = [FORMAT_VERSION]
|
||||
invalid[&"formal_and_artifacts_preserved"] = true
|
||||
return invalid
|
||||
return original
|
||||
|
||||
|
||||
static func _is_integral_json_number(value: Variant, minimum: int, maximum: int) -> bool:
|
||||
if typeof(value) == TYPE_INT:
|
||||
return value >= minimum and value <= maximum
|
||||
@@ -947,7 +1132,7 @@ static func _world_size_is_supported(size: int) -> bool:
|
||||
## Probe only bounded metadata and the fixed binary prefix. An identifiable
|
||||
## unsupported formal format must be rejected before any transaction artifact
|
||||
## is reconciled, even when its compressed payload/checksum is damaged.
|
||||
static func _guard_identified_formal_format(target_path: String) -> Dictionary:
|
||||
static func _guard_identified_formal_format_v1(target_path: String) -> Dictionary:
|
||||
var versions: Array[int] = []
|
||||
var manifest_path := target_path.path_join(MANIFEST_FILE)
|
||||
if FileAccess.file_exists(manifest_path):
|
||||
@@ -1001,6 +1186,15 @@ static func _reconcile_interrupted_formal_save(target_path: String) -> Dictionar
|
||||
if marker_exists and not _formal_transaction_marker_is_valid(marker_path):
|
||||
return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path)
|
||||
|
||||
# A held migration source owns the absent/formally promoted target. Normal
|
||||
# .bak is an older ordinary-save generation and must not take that name.
|
||||
# Verify migration artifacts before allowing normal-save reconciliation.
|
||||
if DirAccess.dir_exists_absolute(target_path + ".migration.original"):
|
||||
if marker_exists:
|
||||
return _failure(&"MIGRATION_FORMAL_TRANSACTION_CONFLICT", target_path)
|
||||
var migration := _reconcile_interrupted_migration(target_path)
|
||||
return _with_context(migration, {&"migration_reconciliation": migration.duplicate(true)})
|
||||
|
||||
var target_status := _package_status(target_path)
|
||||
var temporary_status := _package_status(temporary_path)
|
||||
var backup_status := _package_status(backup_path)
|
||||
@@ -1425,7 +1619,7 @@ static func _package_status(path: String) -> Dictionary:
|
||||
&"current_loadable": false,
|
||||
&"code": loaded[&"code"],
|
||||
}
|
||||
var current_format := int(loaded[&"manifest"].get("format_version", -1)) == FORMAT_VERSION
|
||||
var current_format := _package_domain_matches(loaded)
|
||||
var schema_valid := bool(SimulationSnapshot.validate(loaded[&"snapshot"])[&"ok"])
|
||||
return {
|
||||
&"exists": true,
|
||||
|
||||
@@ -62,7 +62,7 @@ func stage_dictionary_set(
|
||||
return true,
|
||||
func() -> void:
|
||||
if had_key:
|
||||
target[key] = original_value
|
||||
target[key] = _copy_value(original_snapshot)
|
||||
else:
|
||||
target.erase(key)
|
||||
)
|
||||
@@ -94,7 +94,7 @@ func stage_dictionary_erase(
|
||||
return true,
|
||||
func() -> void:
|
||||
if had_key:
|
||||
target[key] = original_value
|
||||
target[key] = _copy_value(original_snapshot)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ var _transaction_ids: Dictionary = {}
|
||||
var _idempotency_keys: Dictionary = {}
|
||||
var _next_transaction_sequence := 1
|
||||
var _pending_transaction_notifications: Dictionary = {}
|
||||
# Transient nested observer boundary; never serialized as gameplay state.
|
||||
var _notification_scopes: Array[Array] = []
|
||||
|
||||
|
||||
func _init(account_definitions: Dictionary = {}) -> void:
|
||||
@@ -308,7 +310,10 @@ func post(
|
||||
_next_transaction_sequence = generated_sequence + 1
|
||||
var posted_entries := get_transaction_entries(effective_id)
|
||||
if emit_notification:
|
||||
transaction_posted.emit(effective_id, posted_entries)
|
||||
if _notification_scopes.is_empty():
|
||||
transaction_posted.emit(effective_id, posted_entries)
|
||||
else:
|
||||
_notification_scopes.back().append(effective_id)
|
||||
else:
|
||||
_pending_transaction_notifications[effective_id] = true
|
||||
return CommandResultType.succeeded(0, {
|
||||
@@ -318,6 +323,36 @@ func post(
|
||||
})
|
||||
|
||||
|
||||
## Retain ordinary post semantics while postponing observer delivery through a
|
||||
## complete event effect/cost boundary. Explicit emit_notification=false remains
|
||||
## owned by its existing caller, including Operations and supply transactions.
|
||||
func notification_scope_depth() -> int:
|
||||
return _notification_scopes.size()
|
||||
|
||||
|
||||
func begin_notification_scope() -> void:
|
||||
_notification_scopes.append([])
|
||||
|
||||
|
||||
func end_notification_scope() -> void:
|
||||
assert(not _notification_scopes.is_empty(), "Balanced notification scope required.")
|
||||
if _notification_scopes.is_empty():
|
||||
return
|
||||
var queued: Array = _notification_scopes.pop_back()
|
||||
if not _notification_scopes.is_empty():
|
||||
_notification_scopes.back().append_array(queued)
|
||||
return
|
||||
var seen: Dictionary = {}
|
||||
for raw_id: Variant in queued:
|
||||
var transaction_id := StringName(raw_id)
|
||||
# Restoring a ledger removes rolled-back IDs. A failure branch can reuse
|
||||
# an ID; deliver the surviving final receipt once in insertion order.
|
||||
if seen.has(transaction_id) or not _transaction_ids.has(transaction_id):
|
||||
continue
|
||||
seen[transaction_id] = true
|
||||
transaction_posted.emit(transaction_id, get_transaction_entries(transaction_id))
|
||||
|
||||
|
||||
func has_pending_transaction_notification(transaction_id: StringName) -> bool:
|
||||
return _pending_transaction_notifications.has(transaction_id)
|
||||
|
||||
|
||||
+200
-35
@@ -5,6 +5,9 @@ extends RefCounted
|
||||
## choice authority. Narrative text is deliberately outside this service; only
|
||||
## localization keys, content ratings/tags and safe replacement IDs are retained.
|
||||
|
||||
signal effect_transaction_started
|
||||
signal effect_transaction_finished
|
||||
|
||||
signal event_registered(definition: EventDefinition)
|
||||
signal event_enqueued(instance: EventInstance)
|
||||
signal event_started(instance: EventInstance)
|
||||
@@ -47,6 +50,7 @@ var _cost_releaser := Callable()
|
||||
var _cost_committer := Callable()
|
||||
var _condition_callbacks: Dictionary = {}
|
||||
var _relationship_service: Variant = null
|
||||
var _candidate_eligibility_guard := Callable()
|
||||
|
||||
|
||||
func configure_callbacks(
|
||||
@@ -70,6 +74,19 @@ func configure_callbacks(
|
||||
return self
|
||||
|
||||
|
||||
## A domain-owned, read-only guard runs before queue mutation and again before
|
||||
## a queued event acquires actors or executes actions. It is rebuilt on restore.
|
||||
func configure_candidate_eligibility_guard(guard: Callable = Callable()) -> void:
|
||||
_candidate_eligibility_guard = guard
|
||||
|
||||
|
||||
func _candidate_permitted(definition: EventDefinition, context: Dictionary) -> bool:
|
||||
if not _candidate_eligibility_guard.is_valid():
|
||||
return true
|
||||
var result: Variant = _candidate_eligibility_guard.call(definition, context.duplicate(true))
|
||||
return typeof(result) == TYPE_BOOL and result
|
||||
|
||||
|
||||
func configure_relationship_service(service: Variant) -> EventService:
|
||||
_relationship_service = service
|
||||
return self
|
||||
@@ -222,6 +239,8 @@ func enqueue_candidate(
|
||||
var context := _dictionary(_value(options, &"context", {})).duplicate(true)
|
||||
context[&"absolute_minute"] = absolute_minute
|
||||
context[&"actor_ids"] = normalized_actor_ids.duplicate()
|
||||
if not _candidate_permitted(source, context) or not _candidate_permitted(definition, context):
|
||||
return _invalid("Event candidate is prevented by placement protection.", {&"reason": &"PLACEMENT_PROTECTION"})
|
||||
var instance := EventInstanceType.new().configure(
|
||||
instance_id,
|
||||
event_id,
|
||||
@@ -256,6 +275,8 @@ func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> C
|
||||
return _invalid("Event processing requires a non-negative minute.")
|
||||
process_expired(absolute_minute)
|
||||
audit_actor_availability(absolute_minute)
|
||||
if pause_requested:
|
||||
return _invalid("Event processing stopped after a fatal failure.", {&"fatal": true})
|
||||
if not _foreground_instance_id.is_empty():
|
||||
return _invalid("A foreground Event is already active.", {
|
||||
&"event_instance_id": _foreground_instance_id,
|
||||
@@ -277,6 +298,10 @@ func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> C
|
||||
var context := _merged_context(instance, absolute_minute, extra_context)
|
||||
if not definition.conditions_match(context, _condition_callbacks):
|
||||
continue
|
||||
var original_definition := get_definition(instance.source_event_id)
|
||||
if original_definition == null or not _candidate_permitted(original_definition, context) or not _candidate_permitted(definition, context):
|
||||
_cancel_instance(instance, &"PLACEMENT_PROTECTION", EventInstanceType.State.CANCELLED)
|
||||
continue
|
||||
if instance.foreground and not _acquire_actor_locks(instance):
|
||||
continue
|
||||
_queue.erase(instance_id)
|
||||
@@ -295,21 +320,27 @@ func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> C
|
||||
if bool(automatic_result.get(&"fatal", false)):
|
||||
instance.state = EventInstanceType.State.ERROR
|
||||
pause_requested = true
|
||||
_finish_instance(instance, false)
|
||||
var finish := _finish_instance(instance, false)
|
||||
if not bool(finish.get(&"ok", false)):
|
||||
automatic_result[&"cost_release"] = finish
|
||||
else:
|
||||
_cancel_instance(instance, &"ACTION_FAILED", EventInstanceType.State.CANCELLED)
|
||||
var cancelled := _cancel_instance(instance, &"ACTION_FAILED", EventInstanceType.State.CANCELLED)
|
||||
if not bool(cancelled.get(&"ok", false)):
|
||||
automatic_result[&"fatal"] = true
|
||||
automatic_result[&"cost_release"] = cancelled
|
||||
return _invalid(instance.failure_reason, automatic_result)
|
||||
if not definition.choices.is_empty():
|
||||
instance.state = EventInstanceType.State.WAITING_CHOICE
|
||||
instance.choice_instance_id = StringName("choice_instance_%08d" % _next_choice_sequence)
|
||||
_next_choice_sequence += 1
|
||||
_advance_choice_attempt(instance)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"event_instance_id": instance.event_instance_id,
|
||||
&"choice_instance_id": instance.choice_instance_id,
|
||||
&"waiting_for_choice": true,
|
||||
})
|
||||
_finish_instance(instance, true)
|
||||
var finish := _finish_instance(instance, true)
|
||||
if not bool(finish.get(&"ok", false)):
|
||||
return _invalid(String(finish.get(&"error", "Event cost release failed.")), finish)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"event_instance_id": instance.event_instance_id,
|
||||
@@ -334,10 +365,12 @@ func process_expired(absolute_minute: int) -> Array[Dictionary]:
|
||||
[],
|
||||
context
|
||||
)
|
||||
_cancel_instance(instance, &"EXPIRED", EventInstanceType.State.EXPIRED)
|
||||
var cancelled := _cancel_instance(instance, &"EXPIRED", EventInstanceType.State.EXPIRED)
|
||||
results.append({
|
||||
&"event_instance_id": instance_id,
|
||||
&"fallback_ok": bool(fallback_result.get(&"ok", false)),
|
||||
&"cost_release": cancelled,
|
||||
&"fatal": bool(cancelled.get(&"fatal", false)),
|
||||
})
|
||||
return results
|
||||
|
||||
@@ -347,6 +380,18 @@ func reserve_choice_cost(
|
||||
choice_id: StringName,
|
||||
absolute_minute: int,
|
||||
extra_context: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
effect_transaction_started.emit()
|
||||
var result := _reserve_choice_cost_inside_transaction(event_instance_id, choice_id, absolute_minute, extra_context)
|
||||
effect_transaction_finished.emit()
|
||||
return result
|
||||
|
||||
|
||||
func _reserve_choice_cost_inside_transaction(
|
||||
event_instance_id: StringName,
|
||||
choice_id: StringName,
|
||||
absolute_minute: int,
|
||||
extra_context: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
var instance := get_instance(event_instance_id)
|
||||
if instance == null:
|
||||
@@ -360,7 +405,10 @@ func reserve_choice_cost(
|
||||
var missing := _missing_actor_ids(instance.actor_ids)
|
||||
if not missing.is_empty():
|
||||
_handle_missing_actors(instance, definition, missing, absolute_minute)
|
||||
return _invalid("Event actor is unavailable.", {&"missing_actor_ids": missing})
|
||||
return _invalid("Event actor is unavailable.", {
|
||||
&"missing_actor_ids": missing, &"fatal": instance.state == EventInstanceType.State.ERROR,
|
||||
&"failure_reason": instance.failure_reason,
|
||||
})
|
||||
var context := _merged_context(instance, absolute_minute, extra_context)
|
||||
if not choice.conditions_match(context, _condition_callbacks):
|
||||
return _invalid("Choice conditions changed and no longer pass.")
|
||||
@@ -384,6 +432,19 @@ func submit_choice(
|
||||
choice_id: StringName,
|
||||
absolute_minute: int,
|
||||
extra_context: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
effect_transaction_started.emit()
|
||||
var result := _submit_choice_inside_transaction(event_instance_id, choice_instance_id, choice_id, absolute_minute, extra_context)
|
||||
effect_transaction_finished.emit()
|
||||
return result
|
||||
|
||||
|
||||
func _submit_choice_inside_transaction(
|
||||
event_instance_id: StringName,
|
||||
choice_instance_id: StringName,
|
||||
choice_id: StringName,
|
||||
absolute_minute: int,
|
||||
extra_context: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
if _processed_choices.has(choice_instance_id):
|
||||
var replay := CommandResultType.from_dictionary(_processed_choices[choice_instance_id])
|
||||
@@ -402,7 +463,10 @@ func submit_choice(
|
||||
var missing := _missing_actor_ids(instance.actor_ids)
|
||||
if not missing.is_empty():
|
||||
_handle_missing_actors(instance, definition, missing, absolute_minute)
|
||||
return _invalid("Event actor is unavailable.", {&"missing_actor_ids": missing})
|
||||
return _invalid("Event actor is unavailable.", {
|
||||
&"missing_actor_ids": missing, &"fatal": instance.state == EventInstanceType.State.ERROR,
|
||||
&"failure_reason": instance.failure_reason,
|
||||
})
|
||||
var choice := definition.get_choice(choice_id)
|
||||
if choice == null:
|
||||
return _not_found("EventChoice", choice_id)
|
||||
@@ -420,30 +484,61 @@ func submit_choice(
|
||||
context
|
||||
)
|
||||
if not bool(transaction_result.get(&"ok", false)):
|
||||
_release_reserved_cost(instance, choice_id, context)
|
||||
var release := _release_reserved_cost(instance, choice_id, context)
|
||||
if not bool(release.get(&"ok", false)):
|
||||
transaction_result[&"fatal"] = true
|
||||
transaction_result[&"cost_release"] = release
|
||||
transaction_result[&"error"] = "Choice action failed and cost release failed: %s" % release.get(&"error", "")
|
||||
if bool(transaction_result.get(&"fatal", false)):
|
||||
var other_release := _release_all_reserved_costs(instance, context, [choice_id])
|
||||
if not bool(other_release.get(&"ok", false)):
|
||||
transaction_result[&"other_cost_release"] = other_release
|
||||
instance.state = EventInstanceType.State.ERROR
|
||||
instance.failure_reason = String(transaction_result.get(&"error", "Failure branch failed."))
|
||||
pause_requested = true
|
||||
_finish_instance(instance, false)
|
||||
_detach_instance(instance)
|
||||
var failed_result := _invalid(
|
||||
String(transaction_result.get(&"error", "Choice Action transaction failed.")),
|
||||
transaction_result
|
||||
)
|
||||
_cache_choice_result(choice_instance_id, failed_result)
|
||||
if instance.state == EventInstanceType.State.WAITING_CHOICE:
|
||||
_advance_choice_attempt(instance)
|
||||
return failed_result
|
||||
var commit_result := _commit_reserved_cost(instance, choice_id, context)
|
||||
if not bool(commit_result.get(&"ok", false)):
|
||||
_rollback_records(transaction_result.get(&"records", []), context)
|
||||
_release_reserved_cost(instance, choice_id, context)
|
||||
var rollback := _rollback_records(transaction_result.get(&"records", []), context)
|
||||
var release := _release_reserved_cost(instance, choice_id, context)
|
||||
if not bool(release.get(&"ok", false)):
|
||||
commit_result[&"fatal"] = true
|
||||
commit_result[&"cost_release"] = release
|
||||
commit_result[&"error"] = "Choice cost commit and release failed: %s" % release.get(&"error", "")
|
||||
if not bool(rollback.get(&"ok", false)):
|
||||
commit_result[&"fatal"] = true
|
||||
commit_result[&"rollback"] = rollback
|
||||
commit_result[&"error"] = "Choice cost failed and action rollback failed: %s" % rollback.get(&"error", "")
|
||||
if bool(commit_result.get(&"fatal", false)):
|
||||
var other_release := _release_all_reserved_costs(instance, context, [choice_id])
|
||||
if not bool(other_release.get(&"ok", false)):
|
||||
commit_result[&"other_cost_release"] = other_release
|
||||
instance.state = EventInstanceType.State.ERROR
|
||||
instance.failure_reason = String(commit_result[&"error"])
|
||||
pause_requested = true
|
||||
_detach_instance(instance)
|
||||
var commit_failure := _invalid(
|
||||
String(commit_result.get(&"error", "Choice cost commit failed.")),
|
||||
commit_result
|
||||
)
|
||||
_cache_choice_result(choice_instance_id, commit_failure)
|
||||
if instance.state == EventInstanceType.State.WAITING_CHOICE:
|
||||
_advance_choice_attempt(instance)
|
||||
return commit_failure
|
||||
instance.selected_choice_id = choice_id
|
||||
_finish_instance(instance, true)
|
||||
var finish := _finish_instance(instance, true)
|
||||
if not bool(finish.get(&"ok", false)):
|
||||
var finish_failure := _invalid(String(finish.get(&"error", "Choice final cost release failed.")), finish)
|
||||
_cache_choice_result(choice_instance_id, finish_failure)
|
||||
return finish_failure
|
||||
var result := _success({
|
||||
&"event_instance_id": event_instance_id,
|
||||
&"choice_instance_id": choice_instance_id,
|
||||
@@ -482,7 +577,9 @@ func cancel_event(event_instance_id: StringName, reason: StringName = &"CANCELLE
|
||||
return _not_found("EventInstance", event_instance_id)
|
||||
if instance.is_terminal():
|
||||
return _invalid("Event is already terminal.")
|
||||
_cancel_instance(instance, reason, EventInstanceType.State.CANCELLED)
|
||||
var cancelled := _cancel_instance(instance, reason, EventInstanceType.State.CANCELLED)
|
||||
if not bool(cancelled.get(&"ok", false)):
|
||||
return _invalid(String(cancelled.get(&"error", "Event cost release failed.")), cancelled)
|
||||
last_error = ""
|
||||
return _success({&"event_instance_id": event_instance_id})
|
||||
|
||||
@@ -765,6 +862,18 @@ func _execute_actions(
|
||||
policy: EventDefinition.TransactionPolicy,
|
||||
failure_actions: Array,
|
||||
context: Dictionary
|
||||
) -> Dictionary:
|
||||
effect_transaction_started.emit()
|
||||
var result := _execute_actions_inside_transaction(actions, policy, failure_actions, context)
|
||||
effect_transaction_finished.emit()
|
||||
return result
|
||||
|
||||
|
||||
func _execute_actions_inside_transaction(
|
||||
actions: Array,
|
||||
policy: EventDefinition.TransactionPolicy,
|
||||
failure_actions: Array,
|
||||
context: Dictionary
|
||||
) -> Dictionary:
|
||||
match policy:
|
||||
EventDefinitionType.TransactionPolicy.SKIP_FAILED:
|
||||
@@ -779,11 +888,13 @@ func _execute_actions(
|
||||
if bool(applied.get(&"ok", false)):
|
||||
records.append({&"action": action, &"token": _copy(applied.get(&"token", null))})
|
||||
else:
|
||||
if bool(applied.get(&"fatal", false)):
|
||||
return applied
|
||||
skipped.append(action.action_id)
|
||||
return {&"ok": true, &"records": records, &"skipped_action_ids": skipped}
|
||||
EventDefinitionType.TransactionPolicy.BRANCH_FAILURE:
|
||||
var primary := _run_rollback_all(actions, context)
|
||||
if bool(primary.get(&"ok", false)):
|
||||
if bool(primary.get(&"ok", false)) or bool(primary.get(&"fatal", false)):
|
||||
return primary
|
||||
var branch := _run_rollback_all(failure_actions, context)
|
||||
if bool(branch.get(&"ok", false)):
|
||||
@@ -807,18 +918,31 @@ func _run_rollback_all(actions: Array, context: Dictionary) -> Dictionary:
|
||||
for action: EventAction in actions:
|
||||
var applied := _execute_action(action, &"apply", context, null)
|
||||
if not bool(applied.get(&"ok", false)):
|
||||
_rollback_records(records, context)
|
||||
return {&"ok": false, &"error": applied.get(&"error", "Action apply failed."), &"records": []}
|
||||
var rollback := _rollback_records(records, context)
|
||||
var failed := {&"ok": false, &"error": applied.get(&"error", "Action apply failed."), &"records": []}
|
||||
if bool(applied.get(&"fatal", false)) or not bool(rollback.get(&"ok", false)):
|
||||
failed[&"fatal"] = true
|
||||
failed[&"rollback"] = rollback
|
||||
failed[&"error"] = "Action transaction rollback failed: %s; %s" % [failed[&"error"], rollback.get(&"error", "")]
|
||||
return failed
|
||||
records.append({&"action": action, &"token": _copy(applied.get(&"token", null))})
|
||||
return {&"ok": true, &"records": records, &"skipped_action_ids": []}
|
||||
|
||||
|
||||
func _rollback_records(raw_records: Variant, context: Dictionary) -> void:
|
||||
func _rollback_records(raw_records: Variant, context: Dictionary) -> Dictionary:
|
||||
if not raw_records is Array:
|
||||
return
|
||||
return {&"ok": false, &"error": "Rollback records are malformed."}
|
||||
var failed_actions: Array[StringName] = []
|
||||
var errors: Array[String] = []
|
||||
# Ask every participant even after a failure; retain reverse apply order.
|
||||
for index: int in range(raw_records.size() - 1, -1, -1):
|
||||
var record: Dictionary = raw_records[index]
|
||||
_execute_action(record[&"action"], &"rollback", context, record.get(&"token"))
|
||||
var action: EventAction = record[&"action"]
|
||||
var result := _execute_action(action, &"rollback", context, record.get(&"token"))
|
||||
if not bool(result.get(&"ok", false)):
|
||||
failed_actions.append(action.action_id)
|
||||
errors.append(String(result.get(&"error", "Rollback participant returned failure.")))
|
||||
return {&"ok": failed_actions.is_empty(), &"failed_action_ids": failed_actions, &"error": "; ".join(errors)}
|
||||
|
||||
|
||||
func _execute_action(
|
||||
@@ -876,7 +1000,7 @@ func _execute_builtin_action(
|
||||
var instance_id := StringName(rollback_token)
|
||||
var scheduled := get_instance(instance_id)
|
||||
if scheduled != null and not scheduled.is_terminal():
|
||||
_cancel_instance(scheduled, &"TRANSACTION_ROLLBACK", EventInstanceType.State.CANCELLED)
|
||||
return _cancel_instance(scheduled, &"TRANSACTION_ROLLBACK", EventInstanceType.State.CANCELLED)
|
||||
return {&"ok": true}
|
||||
EventActionType.Type.ADD_RELATIONSHIP_VALUE, EventActionType.Type.DAMAGE_SHIELD, EventActionType.Type.SET_ROUTE_LOCK:
|
||||
return _execute_relationship_action(action, phase, context, rollback_token)
|
||||
@@ -935,7 +1059,10 @@ func _handle_missing_actors(
|
||||
missing_actor_ids: Array[StringName],
|
||||
absolute_minute: int
|
||||
) -> void:
|
||||
_release_all_reserved_costs(instance, _merged_context(instance, absolute_minute, {}))
|
||||
var release := _release_all_reserved_costs(instance, _merged_context(instance, absolute_minute, {}))
|
||||
if not bool(release.get(&"ok", false)):
|
||||
_mark_release_failure(instance, release)
|
||||
return
|
||||
if definition == null:
|
||||
_cancel_instance(instance, &"DEFINITION_MISSING", EventInstanceType.State.ERROR)
|
||||
return
|
||||
@@ -995,12 +1122,26 @@ func _release_actor_locks(instance: EventInstance) -> void:
|
||||
_foreground_instance_id = &""
|
||||
|
||||
|
||||
func _finish_instance(instance: EventInstance, completed: bool) -> void:
|
||||
_release_all_reserved_costs(instance, instance.context)
|
||||
func _detach_instance(instance: EventInstance) -> void:
|
||||
_release_actor_locks(instance)
|
||||
_queue.erase(instance.event_instance_id)
|
||||
if not instance.deduplication_key.is_empty():
|
||||
_active_deduplication_keys.erase(instance.deduplication_key)
|
||||
|
||||
|
||||
func _mark_release_failure(instance: EventInstance, result: Dictionary) -> void:
|
||||
instance.state = EventInstanceType.State.ERROR
|
||||
instance.failure_reason = String(result.get(&"error", "Event cost release failed."))
|
||||
pause_requested = true
|
||||
_detach_instance(instance)
|
||||
|
||||
|
||||
func _finish_instance(instance: EventInstance, completed: bool) -> Dictionary:
|
||||
var release := _release_all_reserved_costs(instance, instance.context)
|
||||
if not bool(release.get(&"ok", false)):
|
||||
_mark_release_failure(instance, release)
|
||||
return release
|
||||
_detach_instance(instance)
|
||||
if completed:
|
||||
instance.state = EventInstanceType.State.COMPLETED
|
||||
var source := get_definition(instance.source_event_id)
|
||||
@@ -1009,17 +1150,20 @@ func _finish_instance(instance: EventInstance, completed: bool) -> void:
|
||||
if not scope_key.is_empty():
|
||||
_completed_scope_keys[scope_key] = instance.event_instance_id
|
||||
event_finished.emit(instance)
|
||||
return release
|
||||
|
||||
|
||||
func _cancel_instance(instance: EventInstance, reason: StringName, terminal_state: EventInstance.State) -> void:
|
||||
_release_all_reserved_costs(instance, instance.context)
|
||||
_release_actor_locks(instance)
|
||||
_queue.erase(instance.event_instance_id)
|
||||
if not instance.deduplication_key.is_empty():
|
||||
_active_deduplication_keys.erase(instance.deduplication_key)
|
||||
func _cancel_instance(instance: EventInstance, reason: StringName, terminal_state: EventInstance.State) -> Dictionary:
|
||||
var release := _release_all_reserved_costs(instance, instance.context)
|
||||
if not bool(release.get(&"ok", false)):
|
||||
_mark_release_failure(instance, release)
|
||||
event_cancelled.emit(instance, &"COST_RELEASE_FAILED")
|
||||
return release
|
||||
_detach_instance(instance)
|
||||
instance.state = terminal_state
|
||||
instance.failure_reason = String(reason)
|
||||
event_cancelled.emit(instance, reason)
|
||||
return release
|
||||
|
||||
|
||||
func _reserve_cost(cost: Dictionary, context: Dictionary) -> Dictionary:
|
||||
@@ -1037,14 +1181,30 @@ func _release_reserved_cost(instance: EventInstance, choice_id: StringName, cont
|
||||
var result := {&"ok": true}
|
||||
if _cost_releaser.is_valid():
|
||||
result = _normalize_operation_result(_cost_releaser.call(_copy(reservation.get(&"token")), context))
|
||||
instance.reserved_costs.erase(choice_id)
|
||||
elif not _dictionary(reservation.get(&"cost", {})).is_empty():
|
||||
result = {&"ok": false, &"error": "Choice Cost releaser is not configured."}
|
||||
if bool(result.get(&"ok", false)):
|
||||
instance.reserved_costs.erase(choice_id)
|
||||
else:
|
||||
result[&"fatal"] = true
|
||||
result[&"failed_choice_id"] = choice_id
|
||||
return result
|
||||
|
||||
|
||||
func _release_all_reserved_costs(instance: EventInstance, context: Dictionary) -> void:
|
||||
var choice_ids := _sorted_name_keys(instance.reserved_costs)
|
||||
for choice_id: StringName in choice_ids:
|
||||
_release_reserved_cost(instance, choice_id, context)
|
||||
func _release_all_reserved_costs(instance: EventInstance, context: Dictionary, excluded_choice_ids: Array[StringName] = []) -> Dictionary:
|
||||
var failed_ids: Array[StringName] = []
|
||||
var failures: Array[Dictionary] = []
|
||||
for choice_id: StringName in _sorted_name_keys(instance.reserved_costs):
|
||||
if choice_id in excluded_choice_ids:
|
||||
continue
|
||||
var released := _release_reserved_cost(instance, choice_id, context)
|
||||
if not bool(released.get(&"ok", false)):
|
||||
failed_ids.append(choice_id)
|
||||
failures.append(released)
|
||||
if failed_ids.is_empty():
|
||||
return {&"ok": true}
|
||||
return {&"ok": false, &"fatal": true, &"failed_choice_ids": failed_ids,
|
||||
&"failures": failures, &"error": "Event cost release failed for choices: %s" % [failed_ids]}
|
||||
|
||||
|
||||
func _commit_reserved_cost(instance: EventInstance, choice_id: StringName, context: Dictionary) -> Dictionary:
|
||||
@@ -1256,3 +1416,8 @@ func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
|
||||
|
||||
func _not_found(kind: String, entity_id: StringName) -> CommandResult:
|
||||
return _invalid("%s not found: %s" % [kind, entity_id], {&"entity_id": entity_id})
|
||||
|
||||
|
||||
func _advance_choice_attempt(instance: EventInstance) -> void:
|
||||
instance.choice_instance_id = StringName("choice_instance_%08d" % _next_choice_sequence)
|
||||
_next_choice_sequence += 1
|
||||
|
||||
@@ -82,7 +82,7 @@ func configure(
|
||||
customer_capacity = int(_value(options, &"customer_capacity", -1))
|
||||
service_attribute = StringName(_value(options, &"service_attribute", &""))
|
||||
related_facility_id = StringName(_value(options, &"related_facility_id", &""))
|
||||
related_facility_operating = related_facility_id.is_empty()
|
||||
related_facility_operating = related_facility_id.is_empty() and not requires_operating_kitchen()
|
||||
suspension_reason = StringName(_value(options, &"suspension_reason", &""))
|
||||
queue_capacity = maxi(int(_value(options, &"queue_capacity", 0)), 0)
|
||||
base_service_minutes = maxi(int(_value(options, &"base_service_minutes", 1)), 1)
|
||||
@@ -107,6 +107,12 @@ func configure(
|
||||
return self
|
||||
|
||||
|
||||
# Original 8.2 requires each dining venue to use a BACK kitchen. Geometry and
|
||||
# shared logistics are separate contracts; this single-ID rule does not infer either.
|
||||
func requires_operating_kitchen() -> bool:
|
||||
return definition_id in [&"cafeteria", &"restaurant", &"tavern"]
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return not instance_id.is_empty() \
|
||||
and not definition_id.is_empty() \
|
||||
@@ -159,7 +165,7 @@ func is_within_opening_hours(absolute_minute: int) -> bool:
|
||||
func set_operational_conditions(conditions: Dictionary, absolute_minute: int) -> bool:
|
||||
if _has_value(conditions, &"related_facility_id"):
|
||||
related_facility_id = StringName(_value(conditions, &"related_facility_id", &""))
|
||||
related_facility_operating = related_facility_id.is_empty()
|
||||
related_facility_operating = related_facility_id.is_empty() and not requires_operating_kitchen()
|
||||
if StringName(_value(conditions, &"suspension_reason", &"")) in [&"DESTROYED", &"RAIDED"]:
|
||||
suspension_reason = StringName(_value(conditions, &"suspension_reason", &""))
|
||||
if _has_value(conditions, &"durability"):
|
||||
|
||||
@@ -145,6 +145,12 @@ func set_operational_status(
|
||||
var related := StringName(raw_related)
|
||||
if related == instance_id or (not related.is_empty() and not has_instance(related)):
|
||||
return _invalid("Related facility is missing or refers to itself.")
|
||||
if instance.requires_operating_kitchen() and not related.is_empty():
|
||||
var kitchen := get_instance(related)
|
||||
if kitchen.definition_id != &"kitchen" or kitchen.origin.z != WorldService.LAYER_BACK:
|
||||
return _invalid("Dining facilities require one existing BACK kitchen.", {&"reason": &"INVALID_RELATED_KITCHEN"})
|
||||
if not _required_related_placement_matches(instance, kitchen):
|
||||
return _invalid("The BACK kitchen must lie inside this FRONT dining facility's XY footprint.", {&"reason": &"INVALID_RELATED_KITCHEN_PLACEMENT"})
|
||||
var seen := {instance_id: true}
|
||||
var cursor := related
|
||||
while not cursor.is_empty() and has_instance(cursor):
|
||||
@@ -253,7 +259,7 @@ func create_request(
|
||||
)
|
||||
|
||||
|
||||
func enqueue_request(request: Variant, absolute_minute: int = -1) -> CommandResult:
|
||||
func enqueue_request(request: Variant, absolute_minute: int = -1, commit_clock: bool = true) -> CommandResult:
|
||||
if request == null or not request is ServiceRequest:
|
||||
return _invalid("Service request is missing or has an invalid type.")
|
||||
var queued_request: ServiceRequest = request
|
||||
@@ -269,7 +275,11 @@ func enqueue_request(request: Variant, absolute_minute: int = -1) -> CommandResu
|
||||
"Facility queue is full.",
|
||||
{&"instance_id": instance.instance_id, &"queue_capacity": instance.queue_capacity}
|
||||
)
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
# Queue registration can precede the Clock increment in APPLY_COMMANDS.
|
||||
# Preserve the request effective time without claiming participant advancement.
|
||||
if not commit_clock and absolute_minute != current_minute + 1:
|
||||
return _invalid("Deferred queue registration must target the next uncommitted minute.")
|
||||
var minute := _resolve_minute(absolute_minute) if commit_clock else absolute_minute
|
||||
queued_request.enqueued_at = minute
|
||||
_requests[queued_request.request_id] = queued_request
|
||||
_known_request_ids[queued_request.request_id] = true
|
||||
@@ -342,6 +352,76 @@ func cancel_request(request_id: StringName, reason: StringName = &"GUEST_LEFT")
|
||||
return _not_found("Queued service request", request_id)
|
||||
|
||||
|
||||
func _required_related_kind_matches(instance: FacilityInstance) -> bool:
|
||||
if not instance.requires_operating_kitchen():
|
||||
return true
|
||||
var kitchen := get_instance(instance.related_facility_id)
|
||||
return kitchen != null and kitchen.definition_id == &"kitchen" and kitchen.origin.z == WorldService.LAYER_BACK
|
||||
|
||||
|
||||
# Original 2.2 and 8.3 put a BACK kitchen behind the FRONT dining venue.
|
||||
# XY containment is Root's operational interpretation, not a width/origin-x
|
||||
# equality rule. Use actual occupied cells; do not synthesize missing geometry,
|
||||
# rewrite old IDs, or persist this derived predicate.
|
||||
func _required_related_placement_matches(instance: FacilityInstance, proposed_kitchen: FacilityInstance = null) -> bool:
|
||||
if not instance.requires_operating_kitchen():
|
||||
return true
|
||||
var kitchen: FacilityInstance = proposed_kitchen if proposed_kitchen != null else get_instance(instance.related_facility_id)
|
||||
if kitchen == null or kitchen.definition_id != &"kitchen" \
|
||||
or instance.origin.z != WorldService.LAYER_FRONT or kitchen.origin.z != WorldService.LAYER_BACK \
|
||||
or instance.occupied_cells.is_empty() or kitchen.occupied_cells.is_empty():
|
||||
return false
|
||||
var front_xy: Dictionary = {}
|
||||
for cell: Vector3i in instance.occupied_cells:
|
||||
if cell.z != WorldService.LAYER_FRONT:
|
||||
return false
|
||||
front_xy[Vector2i(cell.x, cell.y)] = true
|
||||
for cell: Vector3i in kitchen.occupied_cells:
|
||||
if cell.z != WorldService.LAYER_BACK or not front_xy.has(Vector2i(cell.x, cell.y)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
# Read-only derived eligibility. Legacy cached state/IDs remain untouched until
|
||||
# actual gameplay evaluates a facility or advances one minute.
|
||||
func get_required_kitchen_report(instance_id: StringName) -> Dictionary:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return {}
|
||||
var required := instance.requires_operating_kitchen()
|
||||
var related := instance.related_facility_id
|
||||
var kitchen := get_instance(related)
|
||||
var valid_kind := _required_related_kind_matches(instance)
|
||||
var valid_placement := _required_related_placement_matches(instance)
|
||||
var operating := not required or (valid_placement and _restored_dependency_operates(related, {}))
|
||||
var reason: StringName = &""
|
||||
if required:
|
||||
if related.is_empty(): reason = &"KITCHEN_NOT_LINKED"
|
||||
elif kitchen == null: reason = &"KITCHEN_REMOVED"
|
||||
elif not valid_kind: reason = &"INVALID_RELATED_KITCHEN"
|
||||
elif not valid_placement: reason = &"INVALID_RELATED_KITCHEN_PLACEMENT"
|
||||
elif not kitchen.suspension_reason.is_empty(): reason = &"KITCHEN_SUSPENDED"
|
||||
elif not operating: reason = &"KITCHEN_INACTIVE"
|
||||
return {&"required": required, &"related_facility_id": related, &"exists": kitchen != null, &"valid_kind": valid_kind, &"operating": operating, &"reason": reason}
|
||||
|
||||
|
||||
# Read-only eligibility of a proposed link. Does not select a kitchen, alter an
|
||||
# existing related ID, evaluate state, advance time, or clear persistent suspension.
|
||||
func get_kitchen_placement_report(instance_id: StringName, kitchen_id: StringName) -> Dictionary:
|
||||
var instance := get_instance(instance_id)
|
||||
var kitchen := get_instance(kitchen_id)
|
||||
var required := instance != null and instance.requires_operating_kitchen()
|
||||
var valid_kind := kitchen != null and kitchen.definition_id == &"kitchen" and kitchen.origin.z == WorldService.LAYER_BACK
|
||||
var valid_placement := required and valid_kind and _required_related_placement_matches(instance, kitchen)
|
||||
var reason: StringName = &""
|
||||
if instance == null: reason = &"FACILITY_NOT_FOUND"
|
||||
elif not required: reason = &"KITCHEN_NOT_REQUIRED"
|
||||
elif kitchen == null: reason = &"KITCHEN_REMOVED"
|
||||
elif not valid_kind: reason = &"INVALID_RELATED_KITCHEN"
|
||||
elif not valid_placement: reason = &"INVALID_RELATED_KITCHEN_PLACEMENT"
|
||||
return {&"facility_id": instance_id, &"kitchen_id": kitchen_id, &"required": required, &"exists": kitchen != null, &"valid_kind": valid_kind, &"valid_placement": valid_placement, &"reason": reason}
|
||||
|
||||
|
||||
func refresh_operating_dependencies(minute: int) -> void:
|
||||
var resolved: Dictionary = {}
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
@@ -357,7 +437,7 @@ func _resolve_operating_dependency(id: StringName, minute: int, resolved: Dictio
|
||||
var instance := get_instance(id)
|
||||
instance.apply_pending_policy(minute)
|
||||
var related := instance.related_facility_id
|
||||
instance.related_facility_operating = related.is_empty() or _resolve_operating_dependency(related, minute, resolved, visiting)
|
||||
instance.related_facility_operating = _required_related_placement_matches(instance) and (related.is_empty() or _resolve_operating_dependency(related, minute, resolved, visiting))
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
@@ -686,7 +766,7 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
_next_service_sequence = restored_next_sequence
|
||||
current_minute = restored_minute
|
||||
for instance: FacilityInstance in _instances.values():
|
||||
instance.related_facility_operating = _restored_dependency_operates(instance.related_facility_id, {})
|
||||
instance.related_facility_operating = _required_related_placement_matches(instance) and _restored_dependency_operates(instance.related_facility_id, {})
|
||||
instance.operation_state = FacilityInstanceType.OperationState.SUSPENDED if not instance.suspension_reason.is_empty() else (FacilityInstanceType.OperationState.OPERATING if instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and instance.related_facility_operating else FacilityInstanceType.OperationState.INACTIVE)
|
||||
last_error = ""
|
||||
return true
|
||||
@@ -699,7 +779,7 @@ func _restored_dependency_operates(id: StringName, seen: Dictionary) -> bool:
|
||||
return false
|
||||
seen[id] = true
|
||||
var instance := get_instance(id)
|
||||
return instance.suspension_reason.is_empty() and instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and _restored_dependency_operates(instance.related_facility_id, seen)
|
||||
return _required_related_placement_matches(instance) and instance.suspension_reason.is_empty() and instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and _restored_dependency_operates(instance.related_facility_id, seen)
|
||||
|
||||
|
||||
func _saved_policy_is_valid(policy: Dictionary) -> bool:
|
||||
|
||||
@@ -124,7 +124,9 @@ func is_valid() -> bool:
|
||||
return false
|
||||
if state < State.SPAWNING or state > State.INCAPACITATED:
|
||||
return false
|
||||
if state_entered_at < 0 or planned_exit_at < state_entered_at:
|
||||
# The planned departure is a deadline, independent of later state transitions.
|
||||
# Queue/service completion may legitimately move state_entered_at past it.
|
||||
if state_entered_at < 0 or planned_exit_at < 0:
|
||||
return false
|
||||
if satisfaction_total < 0 or satisfaction_samples < 0 or patience_remaining < 0:
|
||||
return false
|
||||
|
||||
@@ -17,7 +17,7 @@ signal terminal_state_changed(old_state: StringName, new_state: StringName, reas
|
||||
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 4
|
||||
const SNAPSHOT_VERSION := 5
|
||||
const MIN_SUPPORTED_SNAPSHOT_VERSION := 1
|
||||
const MINUTES_PER_DAY := 1_440
|
||||
const DEMAND_INTERVAL_MINUTES := 60
|
||||
@@ -78,6 +78,8 @@ const DAILY_CATEGORIES: Array[StringName] = [
|
||||
&"construction_cash_flow",
|
||||
]
|
||||
|
||||
const FINANCING_CATEGORIES: Array[StringName] = [&"financing_principal_inflows", &"financing_principal_outflows"]
|
||||
|
||||
const DEFAULT_CONFIG := {
|
||||
&"world_seed": 1,
|
||||
&"population_cap": 100,
|
||||
@@ -102,6 +104,8 @@ var _origin_minute := 0
|
||||
# Historical insolvency evidence is retained without mixing it into new loss streaks.
|
||||
var _legacy_calendar_last_day := 0
|
||||
var _legacy_history_last_day := 0
|
||||
# Closed records from versions 1-4 retain their original principal-excluded basis.
|
||||
var _legacy_principal_profit_last_day := 0
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _demand_profiles: Dictionary = {}
|
||||
@@ -670,6 +674,7 @@ func create_snapshot() -> Dictionary:
|
||||
&"schema_version": SNAPSHOT_VERSION,
|
||||
&"legacy_calendar_last_day": _legacy_calendar_last_day,
|
||||
&"legacy_history_last_day": _legacy_history_last_day,
|
||||
&"legacy_principal_profit_last_day": _legacy_principal_profit_last_day,
|
||||
&"origin_minute": _origin_minute,
|
||||
&"current_minute": current_minute,
|
||||
&"config": _string_key_dictionary(_config),
|
||||
@@ -718,6 +723,7 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
# Atomic swap begins only after the entire candidate passed validation.
|
||||
_legacy_calendar_last_day = int(state[&"legacy_calendar_last_day"])
|
||||
_legacy_history_last_day = int(state[&"legacy_history_last_day"])
|
||||
_legacy_principal_profit_last_day = int(state[&"legacy_principal_profit_last_day"])
|
||||
_origin_minute = int(state[&"origin_minute"])
|
||||
current_minute = int(state[&"current_minute"])
|
||||
_config = (state[&"config"] as Dictionary).duplicate(true)
|
||||
@@ -766,6 +772,8 @@ func validate_invariants() -> Dictionary:
|
||||
errors.append({&"code": &"INVALID_CONFIG"})
|
||||
if _population_count < 0 or _population_count > int(_config[&"population_cap"]):
|
||||
errors.append({&"code": &"INVALID_POPULATION"})
|
||||
if _legacy_principal_profit_last_day < 0 or _legacy_principal_profit_last_day > _day_summaries.size():
|
||||
errors.append({&"code": &"INVALID_LEGACY_PRINCIPAL_PROFIT_MARKER"})
|
||||
if _arrears_total < 0 or _consecutive_insolvency_count < 0:
|
||||
errors.append({&"code": &"INVALID_INSOLVENCY_STATE"})
|
||||
if _terminal_state not in [TERMINAL_RUNNING, TERMINAL_BANKRUPT]:
|
||||
@@ -1531,7 +1539,7 @@ func _normalize_categories(source: Dictionary) -> Dictionary:
|
||||
return {&"ok": false, &"error": "Daily categories contain duplicate canonical keys."}
|
||||
for raw_key: Variant in source:
|
||||
if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
||||
or StringName(str(raw_key)) not in DAILY_CATEGORIES:
|
||||
or (StringName(str(raw_key)) not in DAILY_CATEGORIES and StringName(str(raw_key)) not in FINANCING_CATEGORIES):
|
||||
return {
|
||||
&"ok": false,
|
||||
&"error": "Unknown financial category requires explicit classification: %s" % raw_key,
|
||||
@@ -1546,6 +1554,13 @@ func _normalize_categories(source: Dictionary) -> Dictionary:
|
||||
&"error": "Daily category '%s' must be a non-negative integer." % category,
|
||||
}
|
||||
result[category] = int(value)
|
||||
for category: StringName in FINANCING_CATEGORIES:
|
||||
if not source.has(category) and not source.has(String(category)):
|
||||
continue
|
||||
var value: Variant = _value(source, category, null)
|
||||
if not _is_integral_value(value) or int(value) < 0 or int(value) > MAX_FINANCIAL_AMOUNT:
|
||||
return {&"ok": false, &"error": "Principal financing totals must be non-negative checked integers."}
|
||||
result[category] = int(value)
|
||||
return {&"ok": true, &"categories": result}
|
||||
|
||||
|
||||
@@ -1597,6 +1612,11 @@ func _default_daily_apply_result() -> Dictionary:
|
||||
|
||||
|
||||
func _net_profit(categories: Dictionary) -> int:
|
||||
# Original daily expenses include debt repayment; borrowed principal is not sales.
|
||||
return _principal_excluded_profit(categories) - int(categories.get(&"financing_principal_outflows", 0))
|
||||
|
||||
|
||||
func _principal_excluded_profit(categories: Dictionary) -> int:
|
||||
return int(categories.get(&"revenue", 0)) \
|
||||
- int(categories.get(&"refunds", 0)) \
|
||||
- int(categories.get(&"cost_of_goods", 0)) \
|
||||
@@ -1815,6 +1835,8 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String:
|
||||
]
|
||||
if int(_value(snapshot, &"schema_version", 0)) >= 4:
|
||||
top_keys.append_array([&"legacy_calendar_last_day", &"legacy_history_last_day"])
|
||||
if int(_value(snapshot, &"schema_version", 0)) >= 5:
|
||||
top_keys.append(&"legacy_principal_profit_last_day")
|
||||
if not _dictionary_has_exact_string_keys(snapshot, top_keys):
|
||||
return "Operations snapshot has missing or unknown root fields."
|
||||
|
||||
@@ -2170,7 +2192,19 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
for day in range(1, legacy_history + 1):
|
||||
if not (histories[&"day_summaries"] as Dictionary).has(day):
|
||||
return {&"ok": false, &"error": "Missing legacy financial history."}
|
||||
var last_closed_day := 0
|
||||
for day: int in histories[&"day_summaries"]:
|
||||
last_closed_day = maxi(last_closed_day, day)
|
||||
var legacy_principal := last_closed_day
|
||||
if version >= 5:
|
||||
var principal_value: Variant = _value(snapshot, &"legacy_principal_profit_last_day", null)
|
||||
if not _is_integral_value(principal_value):
|
||||
return {&"ok": false, &"error": "Legacy principal profit marker must be an integer."}
|
||||
legacy_principal = int(principal_value)
|
||||
if legacy_principal < 0 or legacy_principal > last_closed_day:
|
||||
return {&"ok": false, &"error": "Legacy principal profit marker exceeds closed history."}
|
||||
var state := {
|
||||
&"legacy_principal_profit_last_day": legacy_principal,
|
||||
&"legacy_calendar_last_day": legacy_calendar,
|
||||
&"legacy_history_last_day": legacy_history,
|
||||
&"origin_minute": restored_origin,
|
||||
@@ -2206,6 +2240,7 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
var candidate := OperationsCoordinator.new(state[&"config"])
|
||||
candidate._legacy_calendar_last_day = legacy_calendar
|
||||
candidate._legacy_history_last_day = legacy_history
|
||||
candidate._legacy_principal_profit_last_day = legacy_principal
|
||||
candidate._origin_minute = restored_origin
|
||||
candidate.current_minute = restored_minute
|
||||
candidate._config = (state[&"config"] as Dictionary).duplicate(true)
|
||||
@@ -2541,7 +2576,8 @@ func _validate_financial_history(errors: Array[Dictionary]) -> void:
|
||||
if not bool(categories[&"ok"]):
|
||||
errors.append({&"code": &"INVALID_DAY_CATEGORIES", &"day": day})
|
||||
continue
|
||||
var net := _net_profit(categories[&"categories"])
|
||||
var net := _principal_excluded_profit(categories[&"categories"]) \
|
||||
if day <= _legacy_principal_profit_last_day else _net_profit(categories[&"categories"])
|
||||
if net != int(_value(summary, &"net_profit", 0)):
|
||||
errors.append({&"code": &"NET_PROFIT_MISMATCH", &"day": day})
|
||||
var difference_value: Variant = _value(summary, &"reconciliation_difference", null)
|
||||
@@ -3156,3 +3192,7 @@ func _boundary_failure(code: StringName, report: Dictionary) -> CommandResult:
|
||||
"An operations boundary failed without committing coordinator state.",
|
||||
{&"boundary": report.duplicate(true)}
|
||||
)
|
||||
|
||||
|
||||
func is_legacy_principal_profit_history(day: int) -> bool:
|
||||
return day > 0 and day <= _legacy_principal_profit_last_day
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
class_name SupplyAutoPurchaseQuotePlans
|
||||
extends "res://scripts/supply/supply_auto_purchase_plans.gd"
|
||||
|
||||
## v1 class remains byte-identical. Only explicit v2 success rows branch here.
|
||||
const QuoteV2 = preload("res://scripts/supply/supply_purchase_quote_v2.gd")
|
||||
const V1 = preload("res://scripts/supply/supply_auto_purchase_plans.gd")
|
||||
const RUNTIME_KEYS: Array[StringName] = [&"runtime_schema_version", &"weather", &"world_seed", &"next_command_id", &"staffing_projection_enabled", &"save_lifecycle", &"command_queue", &"id_generator", &"domain_events", &"rng_streams", &"world", &"path", &"movement_credits", &"mana", &"economy", &"facility_upkeep", &"upkeep_settlement", &"inventory", &"kitchen_production", &"auto_purchase_plans", &"elevators", &"legacy_stairs", &"logistics_dispatcher", &"logistics_requests", &"facilities", &"facility_cleanliness", &"guests", &"staff", &"catalog_manifest", &"progression", &"hired_staff_candidate_ids", &"operations", &"relationships", &"event_definitions", &"events", &"authority", &"defense", &"construction", &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure"]
|
||||
const V2_KEYS: Array[StringName] = [&"warehouse_id", &"item_id", &"enabled", &"target_stock",
|
||||
&"last_attempt_minute", &"last_result", &"last_transaction_id", &"last_quantity", &"last_quote", &"last_total_cost"]
|
||||
|
||||
|
||||
func record_attempt(warehouse_id: StringName, item_id: StringName, minute: int,
|
||||
result: CommandResult, economy: EconomyService) -> bool:
|
||||
if not result.is_success() or not result.details.has(&"purchase_quote"):
|
||||
var previous := get_plan(warehouse_id, item_id)
|
||||
# A later real failure/ordinary purchase replaces only the last attempt;
|
||||
# append-only v2 payment history stays in EconomyService.
|
||||
if previous.has(&"last_quote"):
|
||||
var legacy := _empty_plan(warehouse_id, item_id)
|
||||
for key: StringName in legacy:
|
||||
if previous.has(key): legacy[key] = previous[key]
|
||||
_plans[warehouse_id][item_id] = legacy
|
||||
var accepted := super.record_attempt(warehouse_id, item_id, minute, result, economy)
|
||||
if not accepted and previous.has(&"last_quote"):
|
||||
_plans[warehouse_id][item_id] = previous
|
||||
return accepted
|
||||
if not result.is_success() or not _plans.has(warehouse_id) \
|
||||
or not (_plans[warehouse_id] as Dictionary).has(item_id): return false
|
||||
var plan := get_plan(warehouse_id, item_id)
|
||||
if minute < 0 or minute <= int(plan[&"last_attempt_minute"]): return false
|
||||
var quote: Variant = result.details[&"purchase_quote"]
|
||||
if not quote is Dictionary or not QuoteV2.valid(quote) \
|
||||
or quote[&"item_id"] != String(item_id) \
|
||||
or typeof(result.details.get(&"transaction_id")) != TYPE_STRING_NAME \
|
||||
or typeof(result.details.get(&"quantity")) != TYPE_INT \
|
||||
or result.details[&"quantity"] != quote[&"quantity"] \
|
||||
or typeof(result.details.get(&"total_cost")) != TYPE_INT \
|
||||
or result.details[&"total_cost"] != quote[&"effective_total"]: return false
|
||||
var receipt := QuoteV2.receipt_from_ledger(economy, warehouse_id, item_id, minute, result.details[&"transaction_id"])
|
||||
if var_to_bytes(receipt) != var_to_bytes(quote): return false
|
||||
var row := {&"warehouse_id": String(warehouse_id), &"item_id": String(item_id),
|
||||
&"enabled": plan[&"enabled"], &"target_stock": plan[&"target_stock"],
|
||||
&"last_attempt_minute": minute, &"last_result": &"PURCHASED",
|
||||
&"last_transaction_id": String(result.details[&"transaction_id"]),
|
||||
&"last_quantity": quote[&"quantity"], &"last_quote": quote.duplicate(true),
|
||||
&"last_total_cost": quote[&"effective_total"]}
|
||||
_plans[warehouse_id][item_id] = row
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var rows := get_plans()
|
||||
for row: Dictionary in rows:
|
||||
if row.has(&"last_quote"):
|
||||
return {&"version": 2, &"plans": rows}
|
||||
return super.create_snapshot()
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary, facilities: FacilityService,
|
||||
economy: EconomyService, minute: int) -> bool:
|
||||
last_error = ""
|
||||
if not QuoteV2.keys_match(snapshot, [&"version", &"plans"]) \
|
||||
or typeof(snapshot[&"version"]) != TYPE_INT: return false
|
||||
if snapshot[&"version"] == 1:
|
||||
# The original v1 parser stays literal. A new paid v2 receipt cannot be
|
||||
# represented as a REPLAY_MISMATCH failed old row.
|
||||
var original_parser := V1.new()
|
||||
if not original_parser.restore_snapshot(snapshot, facilities, economy, minute):
|
||||
last_error = original_parser.last_error
|
||||
return false
|
||||
if not _v1_rows_have_no_valid_v2_payment(snapshot[&"plans"], economy): return false
|
||||
_plans = original_parser._plans.duplicate(true)
|
||||
last_error = original_parser.last_error
|
||||
return true
|
||||
if snapshot[&"version"] != 2 or not snapshot[&"plans"] is Array \
|
||||
or facilities == null or economy == null or minute < 0: return false
|
||||
if (snapshot[&"plans"] as Array).size() > facilities.get_instances().size() * Catalog.get_purchase_items().size(): return false
|
||||
var candidate: Dictionary = {}
|
||||
var saw_v2 := false
|
||||
var old_rows: Array[Dictionary] = []
|
||||
for value: Variant in snapshot[&"plans"]:
|
||||
if not value is Dictionary: return false
|
||||
var row: Dictionary = value
|
||||
if row.has(&"last_quote"):
|
||||
if not _valid_v2_row(row, facilities, economy, minute): return false
|
||||
saw_v2 = true
|
||||
else:
|
||||
old_rows.append(row.duplicate(true))
|
||||
if not saw_v2 or not _v1_rows_have_no_valid_v2_payment(old_rows, economy): return false
|
||||
var original_parser := V1.new()
|
||||
if not original_parser.restore_snapshot({&"version": 1, &"plans": old_rows}, facilities, economy, minute): return false
|
||||
# Identifiers are accessed only after every old/new row passed its own
|
||||
# strict alternative, avoiding casts of malformed old rows in a mixed table.
|
||||
for row: Dictionary in snapshot[&"plans"]:
|
||||
var warehouse := StringName(row[&"warehouse_id"])
|
||||
var item := StringName(row[&"item_id"])
|
||||
if candidate.has(warehouse) and (candidate[warehouse] as Dictionary).has(item): return false
|
||||
if not candidate.has(warehouse): candidate[warehouse] = {}
|
||||
candidate[warehouse][item] = row.duplicate(true)
|
||||
var canonical := SupplyAutoPurchaseQuotePlans.new()
|
||||
canonical._plans = candidate
|
||||
if var_to_bytes(canonical.create_snapshot()) != var_to_bytes(snapshot): return false
|
||||
# No live owner changes until both strict alternatives/crosslinks pass.
|
||||
_plans = candidate
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
static func _valid_v2_row(row: Dictionary, facilities: FacilityService,
|
||||
economy: EconomyService, minute: int) -> bool:
|
||||
if not QuoteV2.keys_match(row, V2_KEYS): return false
|
||||
for field: StringName in [&"warehouse_id", &"item_id", &"last_transaction_id"]:
|
||||
if typeof(row[field]) != TYPE_STRING or String(row[field]).strip_edges().is_empty(): return false
|
||||
for field: StringName in [&"target_stock", &"last_attempt_minute", &"last_quantity", &"last_total_cost"]:
|
||||
if typeof(row[field]) != TYPE_INT: return false
|
||||
if typeof(row[&"enabled"]) != TYPE_BOOL or typeof(row[&"last_result"]) != TYPE_STRING_NAME \
|
||||
or row[&"last_result"] != &"PURCHASED" or not row[&"last_quote"] is Dictionary: return false
|
||||
var warehouse := StringName(row[&"warehouse_id"])
|
||||
var item := StringName(row[&"item_id"])
|
||||
if not bool(V1.validate_intent(warehouse, item, row[&"enabled"], row[&"target_stock"], facilities)[&"ok"]): return false
|
||||
var quote: Dictionary = row[&"last_quote"]
|
||||
if not QuoteV2.valid(quote) or quote[&"item_id"] != String(item) \
|
||||
or row[&"last_quantity"] != quote[&"quantity"] or row[&"last_total_cost"] != quote[&"effective_total"] \
|
||||
or row[&"last_attempt_minute"] < 0 or row[&"last_attempt_minute"] > minute: return false
|
||||
var receipt := QuoteV2.receipt_from_ledger(economy, warehouse, item, row[&"last_attempt_minute"], StringName(row[&"last_transaction_id"]))
|
||||
return var_to_bytes(receipt) == var_to_bytes(quote)
|
||||
|
||||
|
||||
static func _v1_rows_have_no_valid_v2_payment(rows: Variant, economy: EconomyService) -> bool:
|
||||
if not rows is Array or economy == null: return false
|
||||
for value: Variant in rows:
|
||||
if not value is Dictionary: return false
|
||||
var row: Dictionary = value
|
||||
if row.has(&"last_quote"): return false
|
||||
if typeof(row.get(&"last_attempt_minute")) != TYPE_INT \
|
||||
or typeof(row.get(&"warehouse_id")) != TYPE_STRING or typeof(row.get(&"item_id")) != TYPE_STRING: continue
|
||||
var minute: int = row[&"last_attempt_minute"]
|
||||
if minute < 0: continue
|
||||
var warehouse := StringName(row.get(&"warehouse_id", ""))
|
||||
var item := StringName(row.get(&"item_id", ""))
|
||||
var paid := economy.get_transaction_id_for_idempotency_key(V1.order_key(warehouse, item, minute))
|
||||
if not paid.is_empty() and not QuoteV2.receipt_from_ledger(economy, warehouse, item, minute, paid).is_empty(): return false
|
||||
return true
|
||||
|
||||
|
||||
static func valid_runtime_format(runtime_data: Dictionary, minute: int) -> bool:
|
||||
if not QuoteV2.keys_match(runtime_data, RUNTIME_KEYS) or typeof(runtime_data.get(&"runtime_schema_version")) != TYPE_INT \
|
||||
or runtime_data[&"runtime_schema_version"] != 15 or not QuoteV2.has_history(runtime_data): return false
|
||||
if not runtime_data.get(&"economy") is Dictionary or not runtime_data.get(&"facilities") is Dictionary \
|
||||
or not runtime_data.get(&"auto_purchase_plans") is Dictionary: return false
|
||||
var economy := EconomyService.new()
|
||||
var facilities := FacilityService.new()
|
||||
if not economy.restore_snapshot(runtime_data[&"economy"]) or not facilities.restore_snapshot(runtime_data[&"facilities"]): return false
|
||||
if not runtime_data.get(&"command_queue") is Dictionary or not runtime_data.get(&"domain_events") is Dictionary: return false
|
||||
var queue := CommandQueue.new()
|
||||
var stream := DomainEventStream.new()
|
||||
if not queue.restore_snapshot(runtime_data[&"command_queue"]) \
|
||||
or not stream.restore_snapshot(runtime_data[&"domain_events"]): return false
|
||||
# Match Runtime's existing domain-minute rule, including held/terminal Ops.
|
||||
var operations: Variant = runtime_data.get(&"operations")
|
||||
if not operations is Dictionary or typeof(operations.get(&"enabled")) != TYPE_BOOL \
|
||||
or typeof(operations.get(&"deferred_runtime_minute")) != TYPE_INT \
|
||||
or not operations.get(&"coordinator") is Dictionary: return false
|
||||
var domain_minute := minute
|
||||
if operations[&"enabled"]:
|
||||
var coordinator := OperationsCoordinator.new()
|
||||
if not coordinator.restore_snapshot(operations[&"coordinator"]): return false
|
||||
if operations[&"deferred_runtime_minute"] == minute or coordinator.is_terminal(): domain_minute = minute - 1
|
||||
var plans := SupplyAutoPurchaseQuotePlans.new()
|
||||
return QuoteV2.valid_history(runtime_data, economy) \
|
||||
and plans.restore_snapshot(runtime_data[&"auto_purchase_plans"], facilities, economy, domain_minute)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cm52patq1djat
|
||||
@@ -1,10 +1,45 @@
|
||||
class_name SupplyManagementService
|
||||
extends RefCounted
|
||||
|
||||
const PurchaseQuoteV2 := preload("res://scripts/supply/supply_purchase_quote_v2.gd")
|
||||
|
||||
## Player commands and deterministic replenishment. Vendor purchases arrive in
|
||||
## the selected warehouse; all transfers inside the tree use real porter tasks.
|
||||
|
||||
func preview_purchase(runtime: Variant, warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary:
|
||||
# Keep the entire ordinary branch literal; only placed UNIQUE Mimi
|
||||
# selects the explicit total-order quote. No attendance/role/floor condition.
|
||||
if not PurchaseQuoteV2.eligible(runtime):
|
||||
return _preview_purchase_v1(runtime, warehouse_id, item_id, quantity)
|
||||
var quote := _preview_purchase_v1(runtime, warehouse_id, item_id, quantity)
|
||||
var item := SupplyCatalog.get_item(item_id)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
|
||||
if facility == null or facility.definition_id != &"warehouse" or item.is_empty() \
|
||||
or not bool(item.get(&"purchasable", false)) or quantity <= 0 or quantity > 10000: return quote
|
||||
var frozen := PurchaseQuoteV2.make(item_id, quantity, int(item[&"unit_price"]))
|
||||
if frozen.is_empty(): return quote
|
||||
var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var space: int = maxi(SupplyCatalog.storage_capacity(facility.definition_id) - SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id) - _incoming_weight(runtime, facility.local_inventory_id), 0)
|
||||
@warning_ignore("integer_division")
|
||||
var slots := space / int(item[&"unit_weight"])
|
||||
quote[&"purchase_quote"] = frozen
|
||||
quote[&"total_cost"] = frozen[&"effective_total"]
|
||||
quote[&"cash_after"] = cash - int(frozen[&"effective_total"])
|
||||
quote[&"max_quantity"] = PurchaseQuoteV2.affordable_quantity(cash, slots, int(item[&"unit_price"]))
|
||||
quote[&"ok"] = false
|
||||
quote[&"reason"] = &""
|
||||
if not facility.allows_logistics():
|
||||
quote[&"reason"] = &"FACILITY_CLOSED"
|
||||
elif int(frozen[&"effective_total"]) > cash:
|
||||
quote[&"reason"] = &"INSUFFICIENT_FUNDS"
|
||||
elif quantity * int(item[&"unit_weight"]) > space:
|
||||
quote[&"reason"] = &"STORAGE_FULL"
|
||||
else:
|
||||
quote[&"ok"] = true
|
||||
return quote
|
||||
|
||||
|
||||
func _preview_purchase_v1(runtime: Variant, warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary:
|
||||
var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var item := SupplyCatalog.get_item(item_id)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
|
||||
@@ -62,12 +97,20 @@ func _purchase_paid(runtime: Variant, warehouse_id: StringName, item_id: StringN
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _purchase_failure(command_id, &"INVENTORY_ERROR")
|
||||
var memo: StringName = SupplyAutoPurchasePlans.receipt_memo(item_id, quantity, int(item[&"unit_price"]), int(quote[&"total_cost"])) if record_auto_attempt else &"supply_purchase"
|
||||
if quote.has(&"purchase_quote"):
|
||||
memo = PurchaseQuoteV2.receipt_memo(quote[&"purchase_quote"])
|
||||
var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, ledger_key, memo, &"", false)
|
||||
if not paid.is_success():
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _purchase_failure(command_id, &"PAYMENT_FAILED")
|
||||
if record_auto_attempt and not runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, CommandResult.succeeded(0, {&"transaction_id": paid.details[&"transaction_id"], &"quantity": quantity, &"unit_price": item[&"unit_price"], &"total_cost": quote[&"total_cost"]}), runtime.economy_service):
|
||||
var recorded := true
|
||||
if record_auto_attempt:
|
||||
if quote.has(&"purchase_quote"):
|
||||
recorded = runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, CommandResult.succeeded(0, {&"transaction_id": paid.details[&"transaction_id"], &"quantity": quantity, &"purchase_quote": quote[&"purchase_quote"], &"total_cost": quote[&"total_cost"]}), runtime.economy_service)
|
||||
else:
|
||||
recorded = runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, CommandResult.succeeded(0, {&"transaction_id": paid.details[&"transaction_id"], &"quantity": quantity, &"unit_price": item[&"unit_price"], &"total_cost": quote[&"total_cost"]}), runtime.economy_service)
|
||||
if not recorded:
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _purchase_failure(command_id, &"PAYMENT_FAILED")
|
||||
@@ -153,7 +196,11 @@ func advance_auto_purchases(runtime: Variant, minute: int) -> Array[Dictionary]:
|
||||
# today's quote is unaffordable. It never stocks, charges or notifies.
|
||||
var receipt := SupplyAutoPurchasePlans.receipt_from_ledger(runtime.economy_service, warehouse_id, item_id, minute, prior_id)
|
||||
if receipt.is_empty():
|
||||
result = _purchase_failure(0, &"REPLAY_MISMATCH")
|
||||
var frozen := PurchaseQuoteV2.receipt_from_ledger(runtime.economy_service, warehouse_id, item_id, minute, prior_id)
|
||||
if frozen.is_empty():
|
||||
result = _purchase_failure(0, &"REPLAY_MISMATCH")
|
||||
else:
|
||||
result = CommandResult.succeeded(0, {&"transaction_id": prior_id, &"quantity": frozen[&"quantity"], &"purchase_quote": frozen, &"total_cost": frozen[&"effective_total"], &"replayed": true})
|
||||
else:
|
||||
result = CommandResult.succeeded(0, {&"transaction_id": prior_id, &"quantity": receipt[&"quantity"], &"unit_price": receipt[&"unit_price"], &"total_cost": receipt[&"total_cost"], &"replayed": true})
|
||||
if (not result.is_success() or not prior_id.is_empty()) and not runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, result, runtime.economy_service):
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
class_name SupplyPurchaseQuoteV2
|
||||
extends RefCounted
|
||||
|
||||
## Explicit total-order quote. v1 unit receipts are never reinterpreted.
|
||||
## Placement uses the existing staff UI plan-first/legacy-live fallback rule.
|
||||
## Total-order rounding reuses Pricing; attendance/role/floor multipliers are not added.
|
||||
const Pricing = preload("res://scripts/facility/facility_pricing.gd")
|
||||
const V1Plans = preload("res://scripts/supply/supply_auto_purchase_plans.gd")
|
||||
const PREFIX := "supply_purchase.quote.v2:"
|
||||
const MIMI := &"heroine_mimi"
|
||||
const MULTIPLIER_BP := 8000
|
||||
const MAX_INT_TEXT := "9223372036854775807"
|
||||
const MAX_INT := 9223372036854775807
|
||||
const KEYS: Array[StringName] = [&"version", &"item_id", &"quantity", &"base_unit_price",
|
||||
&"base_total", &"multiplier_bp", &"effective_total", &"applied_unique_id"]
|
||||
|
||||
|
||||
static func eligible(runtime: Variant) -> bool:
|
||||
var mimi: StaffRuntime = runtime.staff_service.get_staff(MIMI)
|
||||
if mimi == null or mimi.employment_kind != StaffRuntime.EmploymentKind.UNIQUE: return false
|
||||
var assignment: Dictionary = runtime.staff_service.get_work_assignment(MIMI)
|
||||
var facility_id := mimi.assigned_facility_id
|
||||
if not assignment.is_empty(): facility_id = StringName(assignment.get(&"facility_id", &""))
|
||||
return not facility_id.is_empty() and runtime.facility_service.get_instance(facility_id) != null
|
||||
|
||||
|
||||
static func make(item_id: StringName, quantity: int, base_unit_price: int) -> Dictionary:
|
||||
@warning_ignore("integer_division")
|
||||
var maximum_base := (MAX_INT - 5000) / MULTIPLIER_BP
|
||||
if String(item_id).strip_edges().is_empty() or quantity <= 0 or quantity > 10000 or base_unit_price <= 0:
|
||||
return {}
|
||||
@warning_ignore("integer_division")
|
||||
var maximum_unit := maximum_base / quantity
|
||||
if base_unit_price > maximum_unit: return {}
|
||||
var base_total := quantity * base_unit_price
|
||||
return {&"version": 2, &"item_id": String(item_id), &"quantity": quantity,
|
||||
&"base_unit_price": base_unit_price, &"base_total": base_total,
|
||||
&"multiplier_bp": MULTIPLIER_BP, &"effective_total": Pricing.apply_multiplier(base_total, MULTIPLIER_BP),
|
||||
&"applied_unique_id": String(MIMI)}
|
||||
|
||||
|
||||
static func valid(quote: Dictionary) -> bool:
|
||||
if not keys_match(quote, KEYS): return false
|
||||
for key: StringName in [&"version", &"quantity", &"base_unit_price", &"base_total", &"multiplier_bp", &"effective_total"]:
|
||||
if typeof(quote[key]) != TYPE_INT: return false
|
||||
if typeof(quote[&"item_id"]) != TYPE_STRING or typeof(quote[&"applied_unique_id"]) != TYPE_STRING:
|
||||
return false
|
||||
if quote[&"version"] != 2 or quote[&"multiplier_bp"] != MULTIPLIER_BP \
|
||||
or quote[&"applied_unique_id"] != String(MIMI): return false
|
||||
var canonical := make(StringName(quote[&"item_id"]), quote[&"quantity"], quote[&"base_unit_price"])
|
||||
# This also preserves canonical key/container/type order without normalizing
|
||||
# corrupt saved fields or consulting today's catalog/personnel state.
|
||||
return not canonical.is_empty() and var_to_bytes(canonical) == var_to_bytes(quote)
|
||||
|
||||
|
||||
static func affordable_quantity(cash: int, space: int, base_unit_price: int) -> int:
|
||||
var lower := 0
|
||||
var upper := mini(maxi(space, 0), 10000)
|
||||
while lower < upper:
|
||||
@warning_ignore("integer_division")
|
||||
var middle := (lower + upper + 1) / 2
|
||||
var quote := make(&"affordability", middle, base_unit_price)
|
||||
if not quote.is_empty() and quote[&"effective_total"] <= cash:
|
||||
lower = middle
|
||||
else:
|
||||
upper = middle - 1
|
||||
return lower
|
||||
|
||||
|
||||
static func receipt_memo(quote: Dictionary) -> StringName:
|
||||
if not valid(quote): return &""
|
||||
var item: String = quote[&"item_id"]
|
||||
var actor: String = quote[&"applied_unique_id"]
|
||||
return StringName(PREFIX + "%d:%s:%d:%s:%d:%d:%d:%d:%d" % [item.length(), item,
|
||||
actor.length(), actor, quote[&"quantity"], quote[&"base_unit_price"], quote[&"base_total"],
|
||||
quote[&"multiplier_bp"], quote[&"effective_total"]])
|
||||
|
||||
|
||||
static func decode_receipt_memo(memo: StringName) -> Dictionary:
|
||||
var text := String(memo)
|
||||
if not text.begins_with(PREFIX): return {}
|
||||
var item := _take_text(text.substr(PREFIX.length()))
|
||||
if item.is_empty(): return {}
|
||||
var actor := _take_text(item[&"rest"])
|
||||
if actor.is_empty(): return {}
|
||||
var fields: PackedStringArray = String(actor[&"rest"]).split(":", true)
|
||||
if fields.size() != 5: return {}
|
||||
var quote := {&"version": 2, &"item_id": item[&"text"], &"quantity": _positive(fields[0]),
|
||||
&"base_unit_price": _positive(fields[1]), &"base_total": _positive(fields[2]),
|
||||
&"multiplier_bp": _positive(fields[3]), &"effective_total": _positive(fields[4]),
|
||||
&"applied_unique_id": actor[&"text"]}
|
||||
return quote if valid(quote) and receipt_memo(quote) == memo else {}
|
||||
|
||||
|
||||
static func receipt_from_ledger(economy: EconomyService, warehouse_id: StringName,
|
||||
item_id: StringName, minute: int, transaction_id: StringName,
|
||||
automatic := true, command_id := 0) -> Dictionary:
|
||||
var key := V1Plans.order_key(warehouse_id, item_id, minute) if automatic \
|
||||
else StringName("supply.purchase.%d" % command_id)
|
||||
if economy == null or transaction_id.is_empty() or (not automatic and command_id <= 0) \
|
||||
or economy.get_transaction_id_for_idempotency_key(key) != transaction_id:
|
||||
return {}
|
||||
var entries := economy.get_transaction_entries(transaction_id)
|
||||
if entries.size() != 1: return {}
|
||||
var entry: LedgerEntry = entries[0]
|
||||
var quote := decode_receipt_memo(entry.memo_key)
|
||||
if quote.is_empty() or quote[&"item_id"] != String(item_id) \
|
||||
or entry.amount != quote[&"effective_total"] or entry.absolute_minute != minute \
|
||||
or entry.source_entity_id != warehouse_id or entry.category != &"OPERATING_EXPENSE" \
|
||||
or entry.credit_account != &"PLAYER_CASH" or entry.debit_account != &"OPERATING_EXPENSE" \
|
||||
or not entry.related_transaction_id.is_empty(): return {}
|
||||
return quote
|
||||
|
||||
|
||||
static func has_history(runtime_data: Dictionary) -> bool:
|
||||
var economy: Variant = runtime_data.get(&"economy")
|
||||
if economy is Dictionary and economy.get(&"entries") is Array:
|
||||
for entry: Variant in economy[&"entries"]:
|
||||
if entry is Dictionary and typeof(entry.get(&"memo_key")) in [TYPE_STRING, TYPE_STRING_NAME] \
|
||||
and String(entry[&"memo_key"]).begins_with(PREFIX): return true
|
||||
var plans: Variant = runtime_data.get(&"auto_purchase_plans")
|
||||
if plans is Dictionary:
|
||||
if plans.get(&"version") == 2: return true
|
||||
if plans.get(&"plans") is Array:
|
||||
for row: Variant in plans[&"plans"]:
|
||||
if row is Dictionary and row.has(&"last_quote"): return true
|
||||
var queue: Variant = runtime_data.get(&"command_queue")
|
||||
if queue is Dictionary and queue.get("processed") is Array:
|
||||
for result: Variant in queue["processed"]:
|
||||
if result is Dictionary and _result_has_quote(result): return true
|
||||
var stream: Variant = runtime_data.get(&"domain_events")
|
||||
if stream is Dictionary and stream.get("recent_events") is Array:
|
||||
for event: Variant in stream["recent_events"]:
|
||||
if event is Dictionary and event.get("event_type") in ["COMMAND_COMPLETED", "COMMAND_FAILED"] \
|
||||
and event.get("payload") is Dictionary and _result_has_quote(event["payload"]): return true
|
||||
# The uncapped processed queue also keeps history after recent-event eviction.
|
||||
return false
|
||||
|
||||
|
||||
static func valid_history(runtime_data: Dictionary, economy: EconomyService) -> bool:
|
||||
if economy == null: return false
|
||||
var raw_economy: Variant = runtime_data.get(&"economy")
|
||||
if not raw_economy is Dictionary or not raw_economy.get(&"entries") is Array \
|
||||
or not raw_economy.get(&"idempotency") is Array: return false
|
||||
var entries := economy.get_entries()
|
||||
if entries.size() != (raw_economy[&"entries"] as Array).size(): return false
|
||||
var v2_transactions: Dictionary = {}
|
||||
for index: int in entries.size():
|
||||
var entry: LedgerEntry = entries[index]
|
||||
if not String(entry.memo_key).begins_with(PREFIX): continue
|
||||
# The old Economy parser permits legacy coercions. New v2 payment fields
|
||||
# must retain the complete actual nine-field producer representation.
|
||||
var raw: Variant = raw_economy[&"entries"][index]
|
||||
if not raw is Dictionary or var_to_bytes(raw) != var_to_bytes(entry.to_dictionary()): return false
|
||||
v2_transactions[String(entry.transaction_id)] = true
|
||||
for value: Variant in raw_economy[&"idempotency"]:
|
||||
if not value is Dictionary: return false
|
||||
if not v2_transactions.has(value.get(&"transaction_id")): continue
|
||||
if not keys_match(value, [&"key", &"transaction_id"]) \
|
||||
or typeof(value[&"key"]) != TYPE_STRING or typeof(value[&"transaction_id"]) != TYPE_STRING \
|
||||
or var_to_bytes(value) != var_to_bytes({&"key": value[&"key"], &"transaction_id": value[&"transaction_id"]}): return false
|
||||
var required_manual: Dictionary = {}
|
||||
for entry: LedgerEntry in economy.get_entries():
|
||||
if not String(entry.memo_key).begins_with(PREFIX): continue
|
||||
var quote := decode_receipt_memo(entry.memo_key)
|
||||
if quote.is_empty() or entry.source_entity_id.is_empty(): return false
|
||||
var item := StringName(quote[&"item_id"])
|
||||
var automatic := receipt_from_ledger(economy, entry.source_entity_id, item, entry.absolute_minute, entry.transaction_id)
|
||||
if not automatic.is_empty(): continue
|
||||
var command_id := _manual_command_id_for_transaction(economy, entry.transaction_id)
|
||||
if receipt_from_ledger(economy, entry.source_entity_id, item, entry.absolute_minute, entry.transaction_id, false, command_id).is_empty(): return false
|
||||
required_manual[command_id] = quote
|
||||
var queue: Variant = runtime_data.get(&"command_queue")
|
||||
if not queue is Dictionary or not queue.get("processed") is Array: return false
|
||||
var processed: Dictionary = {}
|
||||
for result: Variant in queue["processed"]:
|
||||
if not result is Dictionary or not _valid_result_quotes(result, economy): return false
|
||||
if typeof(result.get("command_id")) != TYPE_INT: return false
|
||||
processed[result["command_id"]] = result
|
||||
# Manual payments have uncapped processed results; removing/replacing their
|
||||
# frozen quote is invalid. Auto plans may legitimately be replaced/pruned,
|
||||
# so their append-only ledger receipt remains sufficient historical proof.
|
||||
for command_id: int in required_manual:
|
||||
if not processed.has(command_id): return false
|
||||
var result: Dictionary = processed[command_id]
|
||||
if result.get("code") != "OK" or not result.get("details") is Dictionary \
|
||||
or not result["details"].get(&"purchase_quote") is Dictionary \
|
||||
or var_to_bytes(result["details"][&"purchase_quote"]) != var_to_bytes(required_manual[command_id]): return false
|
||||
var stream: Variant = runtime_data.get(&"domain_events")
|
||||
if not stream is Dictionary or not stream.get("recent_events") is Array: return false
|
||||
for event: Variant in stream["recent_events"]:
|
||||
if not event is Dictionary: return false
|
||||
if event.get("event_type") not in ["COMMAND_COMPLETED", "COMMAND_FAILED"]: continue
|
||||
var payload: Variant = event.get("payload")
|
||||
if not payload is Dictionary or not _result_has_quote(payload): continue
|
||||
var command_id: Variant = event.get("causation_command_id")
|
||||
if typeof(command_id) != TYPE_INT or not processed.has(command_id) \
|
||||
or typeof(event.get("source_service")) != TYPE_STRING or event["source_service"] != "simulation_runtime" \
|
||||
or var_to_bytes(payload) != var_to_bytes(processed[command_id]): return false
|
||||
return true
|
||||
|
||||
|
||||
static func _result_has_quote(result: Dictionary) -> bool:
|
||||
# Only the owned original_result chain is traversed; arbitrary dictionaries
|
||||
# and schema-looking strings elsewhere are not version authorities.
|
||||
var current: Dictionary = result
|
||||
var seen: Array[Dictionary] = []
|
||||
while true:
|
||||
for visited: Dictionary in seen:
|
||||
if is_same(visited, current): return false
|
||||
seen.append(current)
|
||||
var details: Variant = current.get("details")
|
||||
if not details is Dictionary: return false
|
||||
if details.has(&"purchase_quote"): return true
|
||||
var original: Variant = details.get("original_result")
|
||||
if not original is Dictionary: return false
|
||||
current = original
|
||||
return false
|
||||
|
||||
|
||||
static func _valid_result_quotes(result: Dictionary, economy: EconomyService) -> bool:
|
||||
var current: Dictionary = result
|
||||
var seen: Array[Dictionary] = []
|
||||
while true:
|
||||
for visited: Dictionary in seen:
|
||||
if is_same(visited, current): return false
|
||||
seen.append(current)
|
||||
var details: Variant = current.get("details")
|
||||
if not details is Dictionary: return false
|
||||
if details.has(&"purchase_quote"):
|
||||
# Existing CommandQueue owns all seven result fields; quote results
|
||||
# additionally retain their exact producer string/container types.
|
||||
if not _canonical_result(current): return false
|
||||
var quote: Variant = details[&"purchase_quote"]
|
||||
if not quote is Dictionary or not valid(quote): return false
|
||||
if current["code"] == "OK":
|
||||
if typeof(details.get(&"quantity")) != TYPE_INT or details[&"quantity"] != quote[&"quantity"] \
|
||||
or typeof(details.get(&"total_cost")) != TYPE_INT or details[&"total_cost"] != quote[&"effective_total"] \
|
||||
or typeof(details.get(&"warehouse_id")) != TYPE_STRING_NAME or typeof(details.get(&"item_id")) != TYPE_STRING_NAME \
|
||||
or typeof(details.get(&"transaction_id")) != TYPE_STRING_NAME: return false
|
||||
var entries := economy.get_transaction_entries(details[&"transaction_id"])
|
||||
if entries.size() != 1: return false
|
||||
var receipt := receipt_from_ledger(economy, details[&"warehouse_id"], details[&"item_id"],
|
||||
(entries[0] as LedgerEntry).absolute_minute, details[&"transaction_id"], false, current["command_id"])
|
||||
if var_to_bytes(receipt) != var_to_bytes(quote): return false
|
||||
var original: Variant = details.get("original_result")
|
||||
if not original is Dictionary: return true
|
||||
current = original
|
||||
return false
|
||||
|
||||
|
||||
static func _canonical_result(result: Dictionary) -> bool:
|
||||
if result.size() != 7: return false
|
||||
for key: Variant in result:
|
||||
if typeof(key) != TYPE_STRING or key not in ["code", "command_id", "target_id", "message", "resolution_hints", "details", "is_replay"]: return false
|
||||
if typeof(result["code"]) != TYPE_STRING or String(result["code"]) not in CommandResult.Code.keys() \
|
||||
or typeof(result["command_id"]) != TYPE_INT or result["command_id"] <= 0 \
|
||||
or typeof(result["target_id"]) != TYPE_STRING or typeof(result["message"]) != TYPE_STRING \
|
||||
or not result["resolution_hints"] is Array or not result["details"] is Dictionary \
|
||||
or typeof(result["is_replay"]) != TYPE_BOOL: return false
|
||||
for hint: Variant in result["resolution_hints"]:
|
||||
if typeof(hint) != TYPE_STRING: return false
|
||||
return true
|
||||
|
||||
|
||||
static func _manual_command_id_for_transaction(economy: EconomyService, transaction_id: StringName) -> int:
|
||||
for row: Dictionary in economy.create_snapshot()[&"idempotency"]:
|
||||
var key: String = row[&"key"]
|
||||
if row[&"transaction_id"] != String(transaction_id) or not key.begins_with("supply.purchase."): continue
|
||||
var number := _positive(key.substr("supply.purchase.".length()))
|
||||
if key == "supply.purchase.%d" % number: return number
|
||||
return 0
|
||||
|
||||
|
||||
static func keys_match(value: Dictionary, expected: Array[StringName]) -> bool:
|
||||
if value.size() != expected.size(): return false
|
||||
for key: Variant in value:
|
||||
if typeof(key) != TYPE_STRING_NAME or key not in expected: return false
|
||||
return true
|
||||
|
||||
|
||||
static func _take_text(text: String) -> Dictionary:
|
||||
var delimiter := text.find(":")
|
||||
if delimiter <= 0: return {}
|
||||
var length := _positive(text.substr(0, delimiter))
|
||||
var rest := text.substr(delimiter + 1)
|
||||
if length <= 0 or length >= rest.length() or rest.substr(length, 1) != ":": return {}
|
||||
return {&"text": rest.substr(0, length), &"rest": rest.substr(length + 1)}
|
||||
|
||||
|
||||
static func _positive(text: String) -> int:
|
||||
if text.is_empty() or text.length() > MAX_INT_TEXT.length() or text.substr(0, 1) == "0": return 0
|
||||
for index: int in text.length():
|
||||
var code := text.unicode_at(index)
|
||||
if code < 48 or code > 57: return 0
|
||||
if text.length() == MAX_INT_TEXT.length() and text > MAX_INT_TEXT: return 0
|
||||
return text.to_int()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpihsajte0m6w
|
||||
@@ -9,6 +9,8 @@ signal action_requested(action: StringName)
|
||||
|
||||
const KEY_PREFIX := "ui.business."
|
||||
const COMMAND: StringName = &"set_facility_policy"
|
||||
const KITCHEN_KEY_PREFIX := "ui.business.kitchen."
|
||||
const KITCHEN_COMMAND: StringName = &"set_facility_conditions"
|
||||
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与と施設維持費は発生します。"
|
||||
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与と施設維持費も発生します。"
|
||||
const SERVICE_ATTRIBUTE_NAMES := {
|
||||
@@ -26,6 +28,9 @@ const REASONS := {
|
||||
}
|
||||
var facility_option: OptionButton
|
||||
var price_option: OptionButton
|
||||
var kitchen_option: OptionButton
|
||||
var kitchen_apply_button: Button
|
||||
var kitchen_status_label: Label
|
||||
var business_open_button: CheckButton
|
||||
var quote_label: Label
|
||||
var effect_label: Label
|
||||
@@ -37,10 +42,13 @@ var result_label: Label
|
||||
var selected_facility_id: StringName = &""
|
||||
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _drafts: Dictionary = {}
|
||||
var _pending_commands: Dictionary = {}
|
||||
var _kitchen_drafts: Dictionary = {}
|
||||
var _pending_kitchen_commands: Dictionary = {}
|
||||
var _queue_instance_id := 0
|
||||
var _sequence := 1
|
||||
var _refreshing := false
|
||||
@@ -78,6 +86,16 @@ func _build_interface() -> void:
|
||||
add_child(facility_option)
|
||||
facility_status_label = _label("", "BusinessFacilityStatus")
|
||||
add_child(facility_status_label)
|
||||
kitchen_status_label = _label("", "BusinessKitchenStatus")
|
||||
add_child(kitchen_status_label)
|
||||
kitchen_option = _option("BusinessKitchenOption")
|
||||
kitchen_option.item_selected.connect(func(index: int) -> void:
|
||||
if not _refreshing and not _has_pending(selected_facility_id):
|
||||
_kitchen_drafts[selected_facility_id] = StringName(kitchen_option.get_item_metadata(index))
|
||||
refresh())
|
||||
add_child(kitchen_option)
|
||||
kitchen_apply_button = _button("関連厨房を設定", "ApplyBusinessKitchenButton", submit_kitchen_link)
|
||||
add_child(kitchen_apply_button)
|
||||
add_child(_label("新規の料金"))
|
||||
price_option = _option("BusinessPriceOption")
|
||||
price_option.item_selected.connect(func(_index: int) -> void: _store_draft())
|
||||
@@ -122,6 +140,8 @@ func refresh() -> void:
|
||||
if queue != null and queue.get_instance_id() != _queue_instance_id:
|
||||
_queue_instance_id = queue.get_instance_id()
|
||||
_drafts.clear()
|
||||
_kitchen_drafts.clear()
|
||||
kitchen_option.get_popup().hide()
|
||||
facility_option.get_popup().hide()
|
||||
price_option.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
@@ -134,6 +154,7 @@ func refresh() -> void:
|
||||
_fill_facilities()
|
||||
var row := _facility_row(selected_facility_id)
|
||||
var draft := _selected_draft(row)
|
||||
_fill_kitchens(row)
|
||||
_fill_modes(StringName(draft.get(&"price_mode", &"standard")))
|
||||
business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true)))
|
||||
var pending := _has_pending(selected_facility_id)
|
||||
@@ -159,11 +180,11 @@ func refresh() -> void:
|
||||
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
|
||||
today_label.text = "本日の実績はまだありません。"
|
||||
else:
|
||||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"]
|
||||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), _display_state(row), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"]
|
||||
if is_supply:
|
||||
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||||
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", _display_state(row)]
|
||||
if is_cleaning:
|
||||
facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||||
facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", _display_state(row)]
|
||||
if int(row.get(&"cleanliness", -1)) >= 0:
|
||||
facility_status_label.text += "\n清潔度 %d / 100" % int(row[&"cleanliness"])
|
||||
if not String(row.get(&"cleaning_status", "")).is_empty():
|
||||
@@ -192,6 +213,115 @@ func refresh() -> void:
|
||||
_refreshing = false
|
||||
|
||||
|
||||
func _display_state(row: Dictionary) -> String:
|
||||
var state_name := StringName(row.get(&"state", &""))
|
||||
var state_text := String(STATE_NAMES.get(state_name, "状態確認中"))
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
# Preserve the facility's own fault/staff/access/power/closure reason.
|
||||
# The separate kitchen status label shows the dependency at the same time.
|
||||
if bool(row.get(&"business_open", true)) and state_name in [&"OPEN", &"DEGRADED"] and bool(dependency.get(&"required", false)) and not bool(dependency.get(&"operating", false)):
|
||||
return "関連厨房の稼働待ち" if state_name == &"OPEN" else state_text + " · 関連厨房の稼働待ち"
|
||||
return state_text
|
||||
|
||||
|
||||
func _selected_kitchen(row: Dictionary) -> StringName:
|
||||
for payload: Dictionary in _pending_kitchen_commands.values():
|
||||
if StringName(payload.get(&"facility_id", &"")) == selected_facility_id:
|
||||
return StringName(payload.get(&"conditions", {}).get(&"related_facility_id", &""))
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
return StringName(_kitchen_drafts.get(selected_facility_id, dependency.get(&"related_facility_id", &"")))
|
||||
|
||||
|
||||
func _kitchen_placement(id: StringName) -> Dictionary:
|
||||
if _runtime == null: return {}
|
||||
var facilities: Variant = _runtime.get(&"facility_service")
|
||||
if facilities == null or not facilities.has_method(&"get_kitchen_placement_report"): return {}
|
||||
return facilities.call(&"get_kitchen_placement_report", selected_facility_id, id)
|
||||
|
||||
|
||||
func _fill_kitchens(row: Dictionary) -> void:
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
var required := bool(dependency.get(&"required", false))
|
||||
kitchen_option.visible = required
|
||||
kitchen_apply_button.visible = required
|
||||
kitchen_status_label.visible = required
|
||||
if not required: return
|
||||
var selected := _selected_kitchen(row)
|
||||
if not kitchen_option.get_popup().visible:
|
||||
kitchen_option.clear()
|
||||
kitchen_option.add_item("未接続(飲食サービスを休止)")
|
||||
kitchen_option.set_item_metadata(0, &"")
|
||||
var selected_index := 0
|
||||
var found := selected.is_empty()
|
||||
for kitchen: Dictionary in _rows(&"kitchens"):
|
||||
var id := StringName(kitchen.get(&"facility_id", &""))
|
||||
var placement := _kitchen_placement(id)
|
||||
var label := _facility_name(kitchen)
|
||||
if StringName(placement.get(&"reason", &"")) == &"INVALID_RELATED_KITCHEN_PLACEMENT":
|
||||
label += "(この飲食店の裏に収まりません)"
|
||||
kitchen_option.add_item(label)
|
||||
var index := kitchen_option.item_count - 1
|
||||
kitchen_option.set_item_metadata(index, id)
|
||||
if id == selected:
|
||||
selected_index = index
|
||||
found = true
|
||||
if not found:
|
||||
kitchen_option.add_item("既存設定 %s(利用できません)" % selected)
|
||||
selected_index = kitchen_option.item_count - 1
|
||||
kitchen_option.set_item_metadata(selected_index, selected)
|
||||
kitchen_option.set_item_disabled(selected_index, true)
|
||||
kitchen_option.select(selected_index)
|
||||
var pending := _has_pending(selected_facility_id)
|
||||
kitchen_option.disabled = pending
|
||||
kitchen_apply_button.disabled = pending or selected == StringName(dependency.get(&"related_facility_id", &""))
|
||||
kitchen_apply_button.text = "設定を受理済み…" if pending else "関連厨房を設定"
|
||||
kitchen_status_label.text = "関連厨房:稼働中" if bool(dependency.get(&"operating", false)) else "表層の飲食店と同じ位置範囲・階に収まる裏層の厨房を1つ選んでください。厨房が稼働するまで飲食サービスは休止します。"
|
||||
if StringName(dependency.get(&"reason", &"")) == &"INVALID_RELATED_KITCHEN_PLACEMENT":
|
||||
kitchen_status_label.text = "関連厨房がこの飲食店の裏に収まっていません。同じ位置範囲・階に収まる裏層の厨房を明示的に選んでください。"
|
||||
if StringName(dependency.get(&"reason", &"")) == &"KITCHEN_SUSPENDED":
|
||||
kitchen_status_label.text = "関連厨房が持続休止中です。別の稼働する厨房を明示的に設定できます。"
|
||||
|
||||
|
||||
func submit_kitchen_link() -> void:
|
||||
var row := _facility_row(selected_facility_id)
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
if row.is_empty() or not bool(dependency.get(&"required", false)) or _has_pending(selected_facility_id): return
|
||||
var selected := _selected_kitchen(row)
|
||||
if selected == StringName(dependency.get(&"related_facility_id", &"")): return
|
||||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||||
_set_result("営業サービスへ接続できません。", false)
|
||||
return
|
||||
var payload := {&"facility_id": selected_facility_id, &"conditions": {&"related_facility_id": selected}}
|
||||
var key := StringName("%s%08d" % [KITCHEN_KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _submit_ui_command(KITCHEN_COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
var queued := false
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if queue != null:
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
|
||||
if result.is_success() and queued:
|
||||
_pending_kitchen_commands[int(result.command_id)] = payload.duplicate(true)
|
||||
_set_result("関連厨房の設定を受理しました。次の時間更新で反映します。", true)
|
||||
else:
|
||||
_show_kitchen_result(result, payload)
|
||||
refresh()
|
||||
|
||||
|
||||
func _show_kitchen_result(result: Variant, payload: Dictionary) -> void:
|
||||
if not result.is_success():
|
||||
if StringName(result.details.get(&"reason", &"")) == &"INVALID_RELATED_KITCHEN_PLACEMENT":
|
||||
_set_result("この厨房は飲食店の裏に収まりません。同じ位置範囲・階に収まる裏層の厨房を選び直してください。", false)
|
||||
else:
|
||||
_set_result("関連厨房を設定できません。裏層の実在する厨房を選び直してください。", false)
|
||||
return
|
||||
_kitchen_drafts.erase(StringName(payload.get(&"facility_id", &"")))
|
||||
_set_result("関連厨房の設定を反映しました。", true)
|
||||
|
||||
|
||||
func select_facility(id: StringName) -> void:
|
||||
selected_facility_id = id
|
||||
refresh()
|
||||
@@ -217,7 +347,7 @@ func submit_policy() -> void:
|
||||
var payload := {&"facility_id": selected_facility_id, &"patch": patch}
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||||
var result: Variant = _submit_ui_command(COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
@@ -271,23 +401,34 @@ func _store_draft() -> void:
|
||||
|
||||
func _synchronize_commands(queue: Variant) -> void:
|
||||
_pending_commands.clear()
|
||||
_pending_kitchen_commands.clear()
|
||||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||||
var key := String(metadata.get("idempotency_key", ""))
|
||||
if key.begins_with(KITCHEN_KEY_PREFIX) and key.trim_prefix(KITCHEN_KEY_PREFIX).is_valid_int():
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KITCHEN_KEY_PREFIX)) + 1)
|
||||
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
if StringName(command.get("command_type", &"")) == COMMAND:
|
||||
_pending_commands[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
|
||||
elif StringName(command.get("command_type", &"")) == KITCHEN_COMMAND and String(command.get("idempotency_key", "")).begins_with(KITCHEN_KEY_PREFIX):
|
||||
_pending_kitchen_commands[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
|
||||
|
||||
|
||||
func _has_pending(id: StringName) -> bool:
|
||||
for payload: Dictionary in _pending_commands.values():
|
||||
for payload: Dictionary in _pending_commands.values() + _pending_kitchen_commands.values():
|
||||
if StringName(payload.get(&"facility_id", &"")) == id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if _pending_kitchen_commands.has(int(result.command_id)):
|
||||
var payload: Dictionary = _pending_kitchen_commands[int(result.command_id)]
|
||||
_pending_kitchen_commands.erase(int(result.command_id))
|
||||
_show_kitchen_result(result, payload)
|
||||
refresh()
|
||||
return
|
||||
if not _pending_commands.has(int(result.command_id)):
|
||||
return
|
||||
var payload: Dictionary = _pending_commands[int(result.command_id)]
|
||||
@@ -415,3 +556,17 @@ func _option(node_name: String) -> OptionButton:
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(picker)
|
||||
return picker
|
||||
|
||||
|
||||
func configure_audio(submitter: Callable) -> void:
|
||||
_audio_submitter = submitter
|
||||
|
||||
|
||||
func _submit_ui_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if _audio_submitter.is_valid():
|
||||
return _audio_submitter.call(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
|
||||
@@ -37,6 +37,7 @@ var retreat_button: Button
|
||||
var confirmation: ConfirmationDialog
|
||||
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _raid_labels: Dictionary = {}
|
||||
@@ -148,16 +149,23 @@ func _build_interface() -> void:
|
||||
_style_button.call(button)
|
||||
|
||||
|
||||
func select_raid(raid_id: StringName) -> void:
|
||||
## Reset optional saved presentation without choosing a live raid implicitly.
|
||||
func clear_selection() -> void:
|
||||
_policy_drafts.clear()
|
||||
if result_label != null: result_label.text = ""
|
||||
select_raid(&"", false)
|
||||
|
||||
|
||||
func select_raid(raid_id: StringName, select_first_if_empty: bool = true) -> void:
|
||||
selected_raid_id = raid_id
|
||||
selected_staff_id = &""
|
||||
selected_objective_id = &""
|
||||
_confirmation_context.clear()
|
||||
if confirmation != null: confirmation.hide()
|
||||
refresh()
|
||||
refresh(select_first_if_empty)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
func refresh(select_first_if_empty: bool = true) -> void:
|
||||
if not _built:
|
||||
return
|
||||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||||
@@ -170,11 +178,19 @@ func refresh() -> void:
|
||||
option.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
_report = _runtime.call(&"get_defense_management_report", selected_raid_id) if _runtime != null and _runtime.has_method(&"get_defense_management_report") else {}
|
||||
if selected_raid_id.is_empty() and not select_first_if_empty:
|
||||
# Runtime supplies its normal first-raid report. This inspector alone
|
||||
# presents no selection; it never changes the raid or queued commands.
|
||||
_report[&"selected"] = {}
|
||||
_report[&"defenders"] = []
|
||||
_report[&"assets"] = []
|
||||
_report[&"objectives"] = []
|
||||
_report[&"can_control"] = false
|
||||
_raid_labels = _build_raid_labels(_report.get(&"raids", []))
|
||||
selected_raid_id = StringName((_report.get(&"selected", {}) as Dictionary).get(&"raid_id", &""))
|
||||
selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id)
|
||||
selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id)
|
||||
selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id)
|
||||
selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id, select_first_if_empty)
|
||||
selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id, select_first_if_empty)
|
||||
selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id, select_first_if_empty)
|
||||
var row: Dictionary = _report.get(&"selected", {})
|
||||
var draft: Dictionary = _policy_drafts.get(selected_raid_id, row)
|
||||
var policy := StringName(draft.get(&"retreat_policy", &"HOLD"))
|
||||
@@ -182,7 +198,7 @@ func refresh() -> void:
|
||||
policy_option.select(maxi(POLICIES.find(policy), 0))
|
||||
threshold_spin.set_value_no_signal(int(draft.get(&"defender_retreat_threshold", 0)))
|
||||
_refreshing = false
|
||||
summary_label.text = "進行中・保存済みの防衛戦はありません。" if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"]
|
||||
summary_label.text = ("進行中・保存済みの防衛戦はありません。" if (_report.get(&"raids", []) as Array).is_empty() else "防衛戦を選択してください。") if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"]
|
||||
var names: Array[String] = []
|
||||
for defender: Dictionary in _report.get(&"defenders", []):
|
||||
var employee := _row(&"staff", &"staff_id", defender[&"actor_id"])
|
||||
@@ -282,7 +298,7 @@ func _submit(command_type: StringName, patch: Dictionary = {}) -> void:
|
||||
payload[&"raid_id"] = selected_raid_id
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", command_type, payload, &"player", selected_raid_id, 75, -1, key)
|
||||
var result: Variant = _submit_ui_command(command_type, payload, &"player", selected_raid_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("防衛の指示に応答がありません。", false)
|
||||
return
|
||||
@@ -338,14 +354,14 @@ func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Diction
|
||||
return {}
|
||||
|
||||
|
||||
func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName) -> StringName:
|
||||
func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName, select_first_if_empty: bool = true) -> StringName:
|
||||
if option.get_popup().visible: return selected
|
||||
option.clear()
|
||||
if rows.is_empty():
|
||||
option.add_item("対象なし")
|
||||
option.set_item_metadata(0, &"")
|
||||
return &""
|
||||
var index := 0
|
||||
var index := -1 if selected.is_empty() and not select_first_if_empty else 0
|
||||
for row: Dictionary in rows:
|
||||
var id := StringName(row[id_key])
|
||||
var display_name := String(_raid_labels.get(id, _raid_base_label(row))) if id_key == &"raid_id" else String(row.get(&"display_name", id))
|
||||
@@ -354,7 +370,7 @@ func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selecte
|
||||
option.set_item_metadata(option.item_count - 1, id)
|
||||
if id == selected: index = option.item_count - 1
|
||||
option.select(index)
|
||||
return StringName(option.get_item_metadata(index))
|
||||
return &"" if index < 0 else StringName(option.get_item_metadata(index))
|
||||
|
||||
|
||||
## Cause labels are display-only. Never infer cause from an internal ID or
|
||||
@@ -416,3 +432,17 @@ func _button(text_value: String, node_name: String, callback: Callable) -> Butto
|
||||
button.pressed.connect(callback)
|
||||
if _style_button.is_valid(): _style_button.call(button)
|
||||
return button
|
||||
|
||||
|
||||
func configure_audio(submitter: Callable) -> void:
|
||||
_audio_submitter = submitter
|
||||
|
||||
|
||||
func _submit_ui_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if _audio_submitter.is_valid():
|
||||
return _audio_submitter.call(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
class_name EventLogDialog
|
||||
extends AcceptDialog
|
||||
|
||||
## View the existing retained event stream. No catalog membership, unlock,
|
||||
## replay, acquisition, or serialized state is created by this dialog.
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
const EVENT_NAMES := {
|
||||
&"AUTHORITY_ADVANCE_FAILED": "取締りの進行に失敗",
|
||||
&"AUTHORITY_EVIDENCE_DISCOVERED": "証拠が発見された",
|
||||
&"AUTHORITY_EVIDENCE_REGISTERED": "証拠を登録",
|
||||
&"AUTHORITY_HEAT_CHANGED": "取締りの警戒度が変化",
|
||||
&"AUTHORITY_INSPECTION_EVALUATED": "検査の状況を評価",
|
||||
&"AUTHORITY_INSPECTION_EVALUATION_FAILED": "検査の評価に失敗",
|
||||
&"AUTOSAVE_CHECKPOINT_FAILED": "自動保存に失敗",
|
||||
&"AUTOSAVE_CHECKPOINT_SUCCEEDED": "自動保存が完了",
|
||||
&"COMMAND_COMPLETED": "操作が完了",
|
||||
&"COMMAND_FAILED": "操作に失敗",
|
||||
&"DEFENSE_ADVANCE_FAILED": "防衛の進行に失敗",
|
||||
&"DEFENSE_AUTOMATIC_ROUND_FAILED": "防衛の自動ラウンドに失敗",
|
||||
&"DEFENSE_RAID_CREATED": "襲撃が発生",
|
||||
&"DEFENSE_RAID_RESOLVED": "襲撃の決着",
|
||||
&"DEFENSE_RAID_STATE_CHANGED": "襲撃の状況が変化",
|
||||
&"DEFENSE_ROUND_RESOLVED": "防衛ラウンドの結果",
|
||||
&"DEFENSE_SKILL_APPLIED": "防衛スキルを使用",
|
||||
&"ELEVATOR_ARRIVED": "エレベーターが到着",
|
||||
&"ELEVATOR_BOARDED": "エレベーターに乗車",
|
||||
&"ELEVATOR_COMMUTE_CANCELLED": "エレベーター通勤を中止",
|
||||
&"ELEVATOR_OPERATION_CHANGED": "エレベーターの営業設定を変更",
|
||||
&"ELEVATOR_RESCUE_REQUESTED": "救護のエレベーター搬送を依頼",
|
||||
&"ELEVATOR_TRIP_REQUESTED": "エレベーターの移動を依頼",
|
||||
&"ENTITY_RECOVERY_TELEPORTED": "移動できない対象を復旧",
|
||||
&"EVENT_CANCELLED": "イベントを中止",
|
||||
&"EVENT_CHOICE_RESOLVED": "イベントの選択を処理",
|
||||
&"EVENT_ENQUEUED": "イベントが待機",
|
||||
&"EVENT_FINISHED": "イベントが終了",
|
||||
&"EVENT_STARTED": "イベントが開始",
|
||||
&"EXCAVATION_COMPLETED": "掘削が完了",
|
||||
&"FACILITY_COMPLETED": "施設の建築が完了",
|
||||
&"FACILITY_DEMOLISHED": "施設を撤去",
|
||||
&"FACILITY_INFRASTRUCTURE_PROJECTED": "施設を交通設備に反映",
|
||||
&"FACILITY_STATE_CHANGED": "施設の稼働状態が変化",
|
||||
&"GUEST_AUTO_ARRIVED": "来客が到着",
|
||||
&"GUEST_DECISION_RECORDED": "来客が行き先を検討",
|
||||
&"GUEST_DESPAWNED": "来客が退館",
|
||||
&"GUEST_MOVED": "来客が移動",
|
||||
&"GUEST_QUEUE_ABANDONED": "来客が行列を離脱",
|
||||
&"GUEST_RECOVERY_STARTED": "来客の移動復旧を開始",
|
||||
&"GUEST_SERVICE_SYNC_FAILED": "来客のサービス状態更新に失敗",
|
||||
&"GUEST_SPAWNED": "来客を登録",
|
||||
&"GUEST_STATE_CHANGED": "来客の状態が変化",
|
||||
&"INCIDENT_CREATED": "事件が発生",
|
||||
&"INCIDENT_DETECTOR_ADDED": "事件の発見者を追加",
|
||||
&"INSPECTION_PATH_BLOCKED": "検査員の移動経路が遮断",
|
||||
&"INSPECTION_RESOLVED": "検査の決着",
|
||||
&"INSPECTION_SCHEDULED": "検査が予定された",
|
||||
&"INSPECTION_STARTED": "検査が開始",
|
||||
&"INSPECTION_STATE_CHANGED": "検査の状況が変化",
|
||||
&"INSPECTION_WAYPOINT_INSPECTED": "検査地点の調査が完了",
|
||||
&"INSPECTION_WAYPOINT_REACHED": "検査員が検査地点に到着",
|
||||
&"MANA_STATE_CHANGED": "魔力の供給状態が変化",
|
||||
&"PATH_ACTOR_CROSSLINK_FAILED": "移動状態の整合確認に失敗",
|
||||
&"PATH_ACTOR_STUCK": "移動が滞留",
|
||||
&"RELATIONSHIP_REGISTERED": "人物の関係を登録",
|
||||
&"RELATIONSHIP_ROUTE_LOCK_CHANGED": "人物のルート状態が変化",
|
||||
&"RELATIONSHIP_SHIELD_CHANGED": "人物のシールド値が変化",
|
||||
&"RELATIONSHIP_VALUE_CHANGED": "人物の関係値が変化",
|
||||
&"SAVE_CHECKPOINT_FAILED": "保存に失敗",
|
||||
&"SAVE_CHECKPOINT_SUCCEEDED": "保存が完了",
|
||||
&"SAVE_LOADED": "保存データを読み込み",
|
||||
&"SAVE_LOAD_FAILED": "保存データの読み込みに失敗",
|
||||
&"SAVE_MIGRATION_FAILED": "保存データの移行に失敗",
|
||||
&"SAVE_MIGRATION_SUCCEEDED": "保存データの移行が完了",
|
||||
&"SAVE_RECOVERY_CANDIDATE_LOADED": "選択した復旧データを読み込み",
|
||||
&"SAVE_RECOVERY_FAILED": "保存データの復旧に失敗",
|
||||
&"SAVE_RECOVERY_LOADED": "保存データを復旧",
|
||||
&"SERVICE_COMPLETED": "サービスが完了",
|
||||
&"SERVICE_FAILED": "サービスに失敗",
|
||||
&"SERVICE_INTERRUPTED": "サービスが中断",
|
||||
&"SERVICE_STARTED": "サービスが開始",
|
||||
&"STAFF_CANDIDATE_HIRED": "従業員を雇用",
|
||||
&"STAFF_COLLAPSED": "従業員が倒れた",
|
||||
&"STAFF_COLLAPSE_WITNESSED": "従業員の気絶が目撃された",
|
||||
&"STAFF_MOVED": "従業員が移動",
|
||||
&"STAFF_REGISTERED": "従業員を登録",
|
||||
&"STAFF_REMOVED": "従業員が離脱",
|
||||
&"STAFF_RESCUED": "従業員の救護が完了",
|
||||
&"STAFF_RESCUE_PICKED_UP": "従業員の救護搬送を開始",
|
||||
&"STAFF_SHIFT_REGISTERED": "勤務シフトを登録",
|
||||
&"STAFF_SHIFT_STATE_CHANGED": "勤務シフトの状態が変化",
|
||||
&"STAFF_TASK_ASSIGNED": "仕事を割り当て",
|
||||
&"STAFF_TASK_COMPLETED": "仕事が完了",
|
||||
&"STAFF_TASK_REGISTERED": "仕事を登録",
|
||||
&"STAFF_TASK_REOPENED": "仕事を再募集",
|
||||
&"STAFF_WORK_ASSIGNMENT_CHANGED": "従業員の配属を変更",
|
||||
&"WORLD_TREE_RANK_ADVANCED": "世界樹のランクが上昇",
|
||||
}
|
||||
|
||||
var _runtime: Variant
|
||||
var _event_list: ItemList
|
||||
var _details: Label
|
||||
var _details_scroll: ScrollContainer
|
||||
var _empty_label: Label
|
||||
var _rows: Array[Dictionary] = []
|
||||
var _selected_event_id := ""
|
||||
var _skin_styles: Array[StyleBoxFlat] = []
|
||||
|
||||
|
||||
func configure(runtime: Variant) -> void:
|
||||
_runtime = runtime
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "EventLogDialog"
|
||||
title = "最近のイベント"
|
||||
get_ok_button().text = "閉じる"
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(440, 300)
|
||||
add_child(column)
|
||||
_empty_label = Label.new()
|
||||
_empty_label.name = "EventLogEmptyLabel"
|
||||
_empty_label.text = "イベントの記録はまだありません。"
|
||||
column.add_child(_empty_label)
|
||||
_event_list = ItemList.new()
|
||||
_event_list.name = "EventLogList"
|
||||
_event_list.custom_minimum_size.y = 150
|
||||
_event_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_event_list.item_selected.connect(_select_row)
|
||||
column.add_child(_event_list)
|
||||
# The scroll viewport owns the fixed detail height; a wrapped Label which
|
||||
# has not yet been laid out cannot inflate the first popup minimum height.
|
||||
_details_scroll = ScrollContainer.new()
|
||||
_details_scroll.name = "EventLogDetailsScroll"
|
||||
_details_scroll.custom_minimum_size.y = 90
|
||||
_details_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
column.add_child(_details_scroll)
|
||||
_details = Label.new()
|
||||
_details.name = "EventLogDetails"
|
||||
_details.custom_minimum_size.x = 420
|
||||
_details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_details.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_details_scroll.add_child(_details)
|
||||
_build_skin()
|
||||
apply_layer_skin(LayerSkin.active_layer(self))
|
||||
|
||||
|
||||
func show_events() -> void:
|
||||
refresh()
|
||||
popup_centered(Vector2i(540, 400))
|
||||
|
||||
|
||||
## Full defensive dictionaries are returned for ownership/restore checks.
|
||||
## Publication order and every existing field/type are preserved.
|
||||
func get_event_rows() -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
if not is_instance_valid(_runtime):
|
||||
return rows
|
||||
var stream: Variant = _runtime.get("domain_events")
|
||||
if stream == null or not stream.has_method("recent_events"):
|
||||
return rows
|
||||
for event: SimulationDomainEvent in stream.recent_events():
|
||||
rows.append(event.to_dictionary())
|
||||
return rows
|
||||
|
||||
|
||||
func get_selected_event() -> Dictionary:
|
||||
for row: Dictionary in _rows:
|
||||
if String(row["event_id"]) == _selected_event_id:
|
||||
return row.duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _event_list == null:
|
||||
return
|
||||
_rows = get_event_rows()
|
||||
_event_list.clear()
|
||||
var selected_index := -1
|
||||
for index: int in range(_rows.size()):
|
||||
var row: Dictionary = _rows[index]
|
||||
var presentation := get_event_presentation(row)
|
||||
_event_list.add_item("%s %s — %s" % [presentation[&"time"], presentation[&"title"], presentation[&"targets"]])
|
||||
_event_list.set_item_tooltip(index, presentation[&"supplement"])
|
||||
_event_list.set_item_metadata(index, String(row["event_id"]))
|
||||
if String(row["event_id"]) == _selected_event_id:
|
||||
selected_index = index
|
||||
_empty_label.visible = _rows.is_empty()
|
||||
if selected_index < 0 and not _rows.is_empty():
|
||||
selected_index = 0
|
||||
if selected_index >= 0:
|
||||
_event_list.select(selected_index)
|
||||
_select_row(selected_index)
|
||||
else:
|
||||
_selected_event_id = ""
|
||||
_details.text = ""
|
||||
|
||||
|
||||
func _select_row(index: int) -> void:
|
||||
if index < 0 or index >= _rows.size():
|
||||
return
|
||||
var row: Dictionary = _rows[index]
|
||||
_selected_event_id = String(row["event_id"])
|
||||
var presentation := get_event_presentation(row)
|
||||
_details.text = "%s\n%s\n対象(現在の名称):%s" % [
|
||||
presentation[&"time"], presentation[&"title"], presentation[&"targets"],
|
||||
]
|
||||
_details.tooltip_text = presentation[&"supplement"]
|
||||
|
||||
|
||||
|
||||
## This is presentation of existing notification facts, not a story catalog.
|
||||
## Unknown kinds/targets remain readable placeholders; raw metadata is only a
|
||||
## tooltip supplement. Names are resolved from current validated definitions.
|
||||
func get_event_presentation(row: Dictionary) -> Dictionary:
|
||||
var names: Array[String] = []
|
||||
var raw_ids: Array[String] = []
|
||||
var unknown_count := 0
|
||||
for raw_id: Variant in row.get("entity_ids", []):
|
||||
var entity_id := String(raw_id)
|
||||
raw_ids.append(entity_id)
|
||||
var resolved := _entity_name(StringName(entity_id))
|
||||
if resolved.is_empty():
|
||||
unknown_count += 1
|
||||
else:
|
||||
names.append(resolved)
|
||||
if unknown_count > 0:
|
||||
names.append("名称を確認できない対象(%d件)" % unknown_count)
|
||||
return {
|
||||
&"title": String(EVENT_NAMES.get(StringName(row.get("event_type", "")), "未識別の記録")),
|
||||
&"time": _format_minute(int(row.get("absolute_minute", 0))),
|
||||
&"targets": "、".join(names) if not names.is_empty() else "対象の指定なし",
|
||||
&"supplement": "補足情報\n記録:%s\n種別:%s\n対象:%s\n発行元:%s" % [
|
||||
row.get("event_id", ""), row.get("event_type", ""),
|
||||
"、".join(raw_ids), row.get("source_service", ""),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
func _entity_name(entity_id: StringName) -> String:
|
||||
if not is_instance_valid(_runtime):
|
||||
return ""
|
||||
var catalog: Variant = _runtime.get("mvp_content_catalog")
|
||||
if catalog != null:
|
||||
for content_type: StringName in [&"staff_candidates", &"unique_characters", &"guest_classes", &"species"]:
|
||||
var entry: Dictionary = catalog.get_entry(content_type, entity_id)
|
||||
if not entry.is_empty():
|
||||
return String(entry.get(&"display_name", ""))
|
||||
var facilities: Variant = _runtime.get("facility_service")
|
||||
var definitions: Variant = _runtime.get("definition_registry")
|
||||
if facilities != null and definitions != null:
|
||||
var facility: Variant = facilities.get_instance(entity_id)
|
||||
if facility != null:
|
||||
var instance_definition: Variant = definitions.get_facility(facility.definition_id)
|
||||
if instance_definition != null:
|
||||
var floor_name := "%dF" % (facility.origin.y + 1) if facility.origin.y >= 0 else "B%d" % -facility.origin.y
|
||||
return "%s %s・%s" % [instance_definition.display_name, floor_name, "客用" if facility.origin.z == 0 else "裏方"]
|
||||
var registered_definition: Variant = definitions.get_facility(entity_id)
|
||||
if registered_definition != null:
|
||||
return String(registered_definition.display_name)
|
||||
var guests: Variant = _runtime.get("guest_service")
|
||||
if guests != null and guests.get_guest(entity_id) != null:
|
||||
return "来客"
|
||||
return ""
|
||||
|
||||
func _format_minute(minute: int) -> String:
|
||||
return "%d日目 %02d:%02d" % [minute / 1440 + 1, (minute % 1440) / 60, minute % 60]
|
||||
|
||||
|
||||
func _build_skin() -> void:
|
||||
theme = Theme.new()
|
||||
theme.set_stylebox("panel", "AcceptDialog", _skin_box(Color("f4e3bd"), &"panel_parchment"))
|
||||
var border := _skin_box(Color("49311f"), &"panel_outer")
|
||||
border.expand_margin_top = 32
|
||||
theme.set_stylebox("embedded_border", "Window", border)
|
||||
theme.set_color("title_color", "Window", Color("fff0cf"))
|
||||
for label: Label in [_empty_label, _details]:
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(label, 0)
|
||||
_event_list.add_theme_stylebox_override("panel", _skin_box(Color("f4e3bd"), &"panel_parchment"))
|
||||
# Reuse the existing dark button palette with light selected text.
|
||||
for key: StringName in [&"selected", &"selected_focus"]:
|
||||
_event_list.add_theme_stylebox_override(key, _skin_box(Color("65452d"), &"button_pressed"))
|
||||
for key: StringName in [&"normal", &"hover", &"pressed", &"disabled", &"focus"]:
|
||||
var role: StringName = &"button_normal"
|
||||
if key in [&"hover", &"focus"]:
|
||||
role = &"button_hover"
|
||||
elif key == &"pressed":
|
||||
role = &"button_pressed"
|
||||
elif key == &"disabled":
|
||||
role = &"button_disabled"
|
||||
get_ok_button().add_theme_stylebox_override(key, _skin_box(Color("65452d"), role))
|
||||
LayerSkin.register_toggle_text(get_ok_button(), 0)
|
||||
|
||||
|
||||
func _skin_box(color: Color, role: StringName) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = color
|
||||
style.border_color = Color("a96f2f")
|
||||
style.set_border_width_all(2)
|
||||
style.set_content_margin_all(10)
|
||||
LayerSkin.register_style(style, role, 0)
|
||||
_skin_styles.append(style)
|
||||
return style
|
||||
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
for style: StyleBoxFlat in _skin_styles:
|
||||
LayerSkin.apply_style(style, layer)
|
||||
for label: Label in [_empty_label, _details]:
|
||||
if label != null:
|
||||
LayerSkin.register_text(label, layer)
|
||||
if _event_list != null:
|
||||
_event_list.add_theme_color_override("font_color", Color("d8eeea") if layer == 1 else Color("55371e"))
|
||||
_event_list.add_theme_color_override("font_selected_color", Color("fff0cf"))
|
||||
LayerSkin.register_toggle_text(get_ok_button(), layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddbo74xrnvpxd
|
||||
@@ -66,7 +66,14 @@ func _process(delta: float) -> void:
|
||||
_elapsed = 0
|
||||
refresh()
|
||||
|
||||
func refresh() -> void:
|
||||
## Clear optional saved presentation while rebuilding the actual loaded roster.
|
||||
## Opening or polling the visible observer retains the normal first-row choice.
|
||||
func clear_selection() -> void:
|
||||
selected_guest_id = &""
|
||||
_elapsed = 0.0
|
||||
refresh(false)
|
||||
|
||||
func refresh(select_first_if_empty: bool = true) -> void:
|
||||
if guests == null or _runtime == null:
|
||||
return
|
||||
var service: Variant = _runtime.get(&"guest_service")
|
||||
@@ -80,7 +87,7 @@ func refresh() -> void:
|
||||
for id: StringName in _ids:
|
||||
guests.add_item(String(id))
|
||||
guests.set_item_metadata(guests.item_count-1, id)
|
||||
if not _ids.has(selected_guest_id):
|
||||
if not _ids.has(selected_guest_id) and (not selected_guest_id.is_empty() or select_first_if_empty):
|
||||
selected_guest_id = _ids[0] if not _ids.is_empty() else &""
|
||||
guests.disabled = _ids.is_empty()
|
||||
if not _ids.is_empty(): guests.select(_ids.find(selected_guest_id))
|
||||
@@ -88,7 +95,7 @@ func refresh() -> void:
|
||||
for need: StringName in bars:
|
||||
bars[need].value = report.get(&"needs", {}).get(need, 0)
|
||||
if report.is_empty():
|
||||
details.text = "現在、来客はいません。"
|
||||
details.text = "現在、来客はいません。" if _ids.is_empty() else "来客を選択してください。"
|
||||
priority_label.text = ""
|
||||
decision_label.text = ""
|
||||
return
|
||||
|
||||
@@ -15,4 +15,4 @@ func _draw() -> void:
|
||||
if bool(room.get(&"legacy_stair", false)):
|
||||
PixelArt.stairs(self,room[&"rect"],room[&"rect"].size.x/14.0,true,true)
|
||||
continue
|
||||
PixelArt.room(self,room[&"rect"],room[&"definition_id"],pixel_scale,dungeon,town,true,room[&"origin_x"],{})
|
||||
PixelArt.room(self,room[&"rect"],room[&"definition_id"],pixel_scale,dungeon,town,true,room[&"origin_x"],{},int(room.get(&"level",1)))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
extends RefCounted
|
||||
|
||||
## Medical ward furnishings at the established 16px scale. Cosmetic levels
|
||||
## describe the existing FacilityInstance.level; no treatment or upgrade rule.
|
||||
const Layout := preload("res://scripts/ui/room_presentation.gd")
|
||||
const INK := Color("34413b")
|
||||
const WOOD := Color("8e6348")
|
||||
const WOOD_LIGHT := Color("c39665")
|
||||
const LINEN := Color("eee5c9")
|
||||
|
||||
static func draw_room(canvas: CanvasItem, rect: Rect2, p: float, back: bool, level: int, dungeon: Texture2D) -> void:
|
||||
var tier := clampi(level, 1, 3)
|
||||
var frame := Color("8fa5a2") if back else WOOD_LIGHT
|
||||
var cover := Color("729e96") if back else Color("86b3a8")
|
||||
var wall := Color("acb9a5") if back else Color("d4c7a5")
|
||||
# Medicine occupies only the upper 13px. All established patient anchors
|
||||
# remain available, including the rotated 2x4 footprint and narrow zooms.
|
||||
canvas.draw_rect(Rect2(rect.position+Vector2(1,2)*p, Vector2(rect.size.x-2*p,12*p)),wall)
|
||||
var cabinet_width := maxf(6, minf(20, floorf((rect.size.x/p-6)/3)))
|
||||
for side: int in 2:
|
||||
var x := rect.position.x+2*p if side==0 else rect.end.x-(cabinet_width+2)*p
|
||||
canvas.draw_rect(Rect2(x,rect.position.y+2*p,cabinet_width*p,11*p),INK)
|
||||
canvas.draw_rect(Rect2(x+p,rect.position.y+3*p,(cabinet_width-2)*p,9*p),frame)
|
||||
canvas.draw_rect(Rect2(x+2*p,rect.position.y+4*p,(cabinet_width-4)*p,5*p),Color("577e77"))
|
||||
canvas.draw_rect(Rect2(x+p,rect.position.y+9*p,(cabinet_width-2)*p,p),LINEN)
|
||||
if dungeon != null:
|
||||
# Existing Tiny Dungeon CC0 blue/green bottles, exact packed indices.
|
||||
var index := 113+side
|
||||
canvas.draw_texture_rect_region(dungeon,Rect2(x+(cabinet_width-8)*p/2,rect.position.y+3*p,8*p,8*p),Rect2((index%12)*16,(index/12)*16,16,16))
|
||||
if tier >= 2:
|
||||
canvas.draw_rect(Rect2(x+2*p,rect.position.y+10*p,(cabinet_width-4)*p,p),WOOD)
|
||||
canvas.draw_rect(Rect2(x+cabinet_width*p/2,rect.position.y+10*p,p,p),LINEN)
|
||||
if tier >= 3:
|
||||
canvas.draw_rect(Rect2(x,rect.position.y+2*p,cabinet_width*p,p),Color("d6ad52"))
|
||||
canvas.draw_rect(Rect2(x+2*p,rect.position.y+5*p,p,3*p),Color("c3d1c5"))
|
||||
var mid := rect.get_center().x
|
||||
canvas.draw_rect(Rect2(mid-3*p,rect.position.y+3*p,6*p,9*p),INK)
|
||||
canvas.draw_rect(Rect2(mid-2*p,rect.position.y+4*p,4*p,7*p),LINEN)
|
||||
canvas.draw_rect(Rect2(mid,rect.position.y+6*p,p,4*p),Color("52836c"))
|
||||
canvas.draw_rect(Rect2(mid-p,rect.position.y+5*p,3*p,2*p),cover)
|
||||
if tier >= 3:
|
||||
canvas.draw_rect(Rect2(mid-3*p,rect.position.y+3*p,6*p,p),Color("d6ad52"))
|
||||
for anchor: Vector2 in Layout.slots(rect,p,&"infirmary"):
|
||||
var at := anchor+Vector2(-7,-9)*p
|
||||
canvas.draw_rect(Rect2(at+Vector2(0,2)*p,Vector2(13,7)*p),INK)
|
||||
canvas.draw_rect(Rect2(at+Vector2(1,2)*p,Vector2(11,4)*p),LINEN)
|
||||
canvas.draw_rect(Rect2(at+Vector2(5,2)*p,Vector2(7,5)*p),cover)
|
||||
canvas.draw_rect(Rect2(at,Vector2(1,10)*p),frame)
|
||||
canvas.draw_rect(Rect2(at+Vector2(12,5)*p,Vector2(1,5)*p),frame)
|
||||
canvas.draw_rect(Rect2(at+Vector2(2,2)*p,Vector2(3,2)*p),Color("fff0d6"))
|
||||
if tier >= 2:
|
||||
canvas.draw_rect(Rect2(at+Vector2(5,4)*p,Vector2(7,1)*p),LINEN)
|
||||
if tier >= 3:
|
||||
canvas.draw_rect(Rect2(at+Vector2(6,6)*p,Vector2(6,1)*p),Color("c3d1c5"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc4xv2kx0ah3u
|
||||
@@ -0,0 +1,144 @@
|
||||
extends ConfirmationDialog
|
||||
|
||||
## Presentation only: listing never reconciles, migrates or deletes packages.
|
||||
## New package names use OS crypto, independently of every simulation RNG.
|
||||
signal new_slot_requested(path: String, display_name: String)
|
||||
signal load_slot_requested(path: String)
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
const OWNED_NAME_PATTERN := "^manual_ui_[0-9a-f]{32}$"
|
||||
var _directory := ""
|
||||
var _mode := ""
|
||||
var _new_path := ""
|
||||
var _new_name := ""
|
||||
var _message: Label
|
||||
var _slots: ItemList
|
||||
var _rows: Array[Dictionary] = []
|
||||
|
||||
func _ready() -> void:
|
||||
name = "ManualSaveSlotsDialog"
|
||||
cancel_button_text = "戻る"
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(460, 220)
|
||||
add_child(column)
|
||||
_message = Label.new()
|
||||
_message.name = "ManualSaveSlotsMessage"
|
||||
_message.custom_minimum_size.x = 460
|
||||
_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
column.add_child(_message)
|
||||
_slots = ItemList.new()
|
||||
_slots.name = "ManualSaveSlotsList"
|
||||
_slots.custom_minimum_size = Vector2(460, 150)
|
||||
_slots.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_slots.item_selected.connect(_select_slot)
|
||||
column.add_child(_slots)
|
||||
confirmed.connect(_confirm_choice)
|
||||
canceled.connect(_clear_choice)
|
||||
close_requested.connect(_clear_choice)
|
||||
apply_layer_skin(LayerSkin.active_layer(self))
|
||||
|
||||
func show_new_save(current_path: String) -> void:
|
||||
_clear_choice()
|
||||
_mode = "new"
|
||||
_directory = current_path.get_base_dir()
|
||||
title = "旧保存を残して新しく保存"
|
||||
ok_button_text = "新しく保存"
|
||||
_slots.hide()
|
||||
# No directory is created until the player explicitly confirms.
|
||||
var token: PackedByteArray = Crypto.new().generate_random_bytes(16)
|
||||
if token.size() == 16:
|
||||
_new_path = _directory.path_join("manual_ui_" + token.hex_encode())
|
||||
_new_name = "手動保存 " + Time.get_datetime_string_from_system(false, true)
|
||||
var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path)
|
||||
get_ok_button().disabled = not fresh
|
||||
_message.text = "今の進行は、元の保存とは別に保存します。\n元の保存は残り、あとから読込一覧で選べます。\n\n" + _new_name if fresh else "新しい保存先を用意できませんでした。\n元の保存は変更していません。"
|
||||
popup_centered(Vector2i(520, 300))
|
||||
get_cancel_button().grab_focus()
|
||||
|
||||
func show_load_slots(current_path: String) -> void:
|
||||
_clear_choice()
|
||||
_mode = "load"
|
||||
_directory = current_path.get_base_dir()
|
||||
title = "保存スロットを選んで読込"
|
||||
ok_button_text = "選んで読込"
|
||||
_message.text = "読み込む保存を選んでください。\n現在の未保存の進行は、選んだ保存で置き換わります。"
|
||||
_slots.show()
|
||||
_slots.clear()
|
||||
_rows = read_slot_rows(_directory)
|
||||
var selection := -1
|
||||
for index: int in range(_rows.size()):
|
||||
var row: Dictionary = _rows[index]
|
||||
_slots.add_item(row[&"label"])
|
||||
_slots.set_item_disabled(index, not row[&"loadable"])
|
||||
if row[&"path"] == current_path and row[&"loadable"]:
|
||||
selection = index
|
||||
if selection < 0:
|
||||
for index: int in range(_rows.size()):
|
||||
if _rows[index][&"loadable"]:
|
||||
selection = index
|
||||
break
|
||||
get_ok_button().disabled = selection < 0
|
||||
if selection >= 0:
|
||||
_slots.select(selection)
|
||||
else:
|
||||
_message.text = "読み込める保存が見つかりません。\n保存データは変更していません。"
|
||||
popup_centered(Vector2i(520, 360))
|
||||
if selection >= 0: _slots.grab_focus()
|
||||
else: get_cancel_button().grab_focus()
|
||||
|
||||
static func is_owned_slot_name(value: String) -> bool:
|
||||
if value == "manual_ui": return true
|
||||
var pattern := RegEx.new()
|
||||
return pattern.compile(OWNED_NAME_PATTERN) == OK and pattern.search(value) != null
|
||||
|
||||
static func is_fresh_slot(path: String) -> bool:
|
||||
if not path.begins_with("user://") or not is_owned_slot_name(path.get_file()) or path.get_file() == "manual_ui": return false
|
||||
# Reject any existing package, transaction, recovery family, file or link.
|
||||
for suffix: String in ["", ".tmp", ".bak", SaveService.MIGRATION_BACKUP_SUFFIX, SaveService.SAVE_TRANSACTION_SUFFIX]:
|
||||
var absolute: String = ProjectSettings.globalize_path(path + suffix)
|
||||
var parent: DirAccess = DirAccess.open(absolute.get_base_dir())
|
||||
if FileAccess.file_exists(absolute) or DirAccess.dir_exists_absolute(absolute) or (parent != null and parent.is_link(absolute.get_file())): return false
|
||||
return true
|
||||
|
||||
static func read_slot_rows(directory: String) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
if not directory.begins_with("user://"): return rows
|
||||
var source: DirAccess = DirAccess.open(directory)
|
||||
if source == null: return rows
|
||||
var names: PackedStringArray = source.get_directories()
|
||||
names.sort()
|
||||
for slot: String in names:
|
||||
if not is_owned_slot_name(slot) or source.is_link(slot): continue
|
||||
var path: String = directory.path_join(slot)
|
||||
# Manifest text alone is never authority for a loadable package.
|
||||
var inspected: Dictionary = SaveService.inspect_snapshot(path)
|
||||
var loadable: bool = bool(inspected.get(&"ok", false))
|
||||
var label: String = "手動保存(最初のスロット)" if slot == "manual_ui" else "手動保存"
|
||||
if loadable:
|
||||
label = String(inspected.get(&"manifest", {}).get("display_name", label))
|
||||
else:
|
||||
label += " — 読込不可(保存は残っています)"
|
||||
label = "%d. %s" % [rows.size() + 1, label]
|
||||
rows.append({&"path": path, &"label": label, &"loadable": loadable})
|
||||
return rows
|
||||
|
||||
func _select_slot(index: int) -> void:
|
||||
get_ok_button().disabled = index < 0 or index >= _rows.size() or not _rows[index][&"loadable"]
|
||||
|
||||
func _confirm_choice() -> void:
|
||||
if _mode == "new" and is_fresh_slot(_new_path):
|
||||
new_slot_requested.emit(_new_path, _new_name)
|
||||
elif _mode == "load":
|
||||
var selected: PackedInt32Array = _slots.get_selected_items()
|
||||
if selected.size() == 1 and selected[0] >= 0 and selected[0] < _rows.size() and _rows[selected[0]][&"loadable"]:
|
||||
load_slot_requested.emit(_rows[selected[0]][&"path"])
|
||||
_clear_choice()
|
||||
|
||||
func _clear_choice() -> void:
|
||||
_mode = ""
|
||||
_new_path = ""
|
||||
_new_name = ""
|
||||
_rows.clear()
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
if _message != null: LayerSkin.register_text(_message, layer)
|
||||
LayerSkin.apply_tree(self, layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkhs10xbqj03b
|
||||
@@ -3,6 +3,8 @@ extends VBoxContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
signal status_requested(message: String, success: bool)
|
||||
|
||||
## Projection only: authoritative close and ledger entries own all amounts.
|
||||
var _initial_layer := 0
|
||||
var _runtime: Variant
|
||||
@@ -13,6 +15,18 @@ var _closed_label: Label
|
||||
var _facility_label: Label
|
||||
var _selected_day := -1
|
||||
var _picker_days: Array[int] = []
|
||||
var loan_option: OptionButton
|
||||
var loan_amount: LineEdit
|
||||
var repay_button: Button
|
||||
var repay_all_button: Button
|
||||
var _loan_box: VBoxContainer
|
||||
var _loan_hint: Label
|
||||
var _loan_result: Label
|
||||
var _selected_loan: StringName = &""
|
||||
var _loan_pending_command := 0
|
||||
var _loan_queue_instance := 0
|
||||
var _audio_submitter := Callable()
|
||||
|
||||
|
||||
|
||||
func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer := 0) -> void:
|
||||
@@ -27,6 +41,7 @@ func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer :
|
||||
LayerSkin.register_text(heading, _initial_layer)
|
||||
add_child(heading)
|
||||
_cash_label = _label()
|
||||
_build_repayment_controls(style_button)
|
||||
_day_picker = OptionButton.new()
|
||||
_day_picker.name = "LedgerDayPicker"
|
||||
if style_button.is_valid(): style_button.call(_day_picker)
|
||||
@@ -36,6 +51,8 @@ func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer :
|
||||
add_child(_day_picker)
|
||||
_closed_label = _label()
|
||||
_facility_label = _label()
|
||||
if _runtime != null and _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_repayment_completed)
|
||||
refresh()
|
||||
|
||||
|
||||
@@ -97,6 +114,7 @@ func refresh() -> void:
|
||||
return
|
||||
if _day_picker.get_popup().visible:
|
||||
return
|
||||
_refresh_repayment_controls()
|
||||
var report := get_report()
|
||||
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]]
|
||||
var summary: Dictionary = report[&"selected_close"]
|
||||
@@ -124,7 +142,11 @@ func refresh() -> void:
|
||||
var other := 0
|
||||
for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]:
|
||||
other += int(totals.get(key, 0))
|
||||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n日次利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||||
if totals.has(&"financing_principal_inflows") or totals.has(&"financing_principal_outflows"):
|
||||
_closed_label.text += "\n借入元本の入金 %dG(売上外) / 元本返済 %dG(決算経費)" % [int(totals.get(&"financing_principal_inflows", 0)), int(totals.get(&"financing_principal_outflows", 0))]
|
||||
if _runtime.operations_coordinator.is_legacy_principal_profit_history(int(summary[&"day"])):
|
||||
_closed_label.text += "\n旧版の確定履歴:元本返済を利益から除外した計算を保持"
|
||||
var legacy: bool = _runtime.operations_coordinator.is_legacy_financial_history(int(summary[&"day"]))
|
||||
_closed_label.text += "\n%s %d回%s" % ["旧版の連続未払い" if legacy else "連続赤字", int(summary.get(&"consecutive_insolvency_count", 0)), "" if legacy else "(3回でゲーム終了)"]
|
||||
var close_minute := int(summary[&"closed_at"]) % 1440
|
||||
@@ -160,3 +182,154 @@ func _label() -> Label:
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
add_child(label)
|
||||
return label
|
||||
|
||||
|
||||
func configure_audio(submitter: Callable) -> void:
|
||||
_audio_submitter = submitter
|
||||
|
||||
|
||||
func _build_repayment_controls(style_button: Callable) -> void:
|
||||
_loan_hint = _label()
|
||||
_loan_hint.name = "LoanRepaymentHint"
|
||||
_loan_box = VBoxContainer.new()
|
||||
_loan_box.name = "LoanRepaymentControls"
|
||||
_loan_box.add_theme_constant_override("separation", 6)
|
||||
add_child(_loan_box)
|
||||
loan_option = OptionButton.new()
|
||||
loan_option.name = "LoanRepaymentOption"
|
||||
if style_button.is_valid(): style_button.call(loan_option)
|
||||
_loan_box.add_child(loan_option)
|
||||
loan_option.item_selected.connect(func(index: int) -> void:
|
||||
_selected_loan = StringName(loan_option.get_item_metadata(index))
|
||||
loan_amount.text = ""
|
||||
_refresh_repayment_controls())
|
||||
loan_amount = LineEdit.new()
|
||||
loan_amount.name = "LoanRepaymentAmount"
|
||||
loan_amount.placeholder_text = "返済する元本(整数G)"
|
||||
loan_amount.tooltip_text = "残元本と手持ちを超えない整数額を入力"
|
||||
_loan_box.add_child(loan_amount)
|
||||
loan_amount.text_changed.connect(func(_text: String) -> void: _refresh_repayment_controls())
|
||||
loan_amount.text_submitted.connect(func(_text: String) -> void: submit_repayment(false))
|
||||
repay_button = Button.new()
|
||||
repay_button.name = "LoanRepaymentButton"
|
||||
repay_button.text = "指定額を返済"
|
||||
if style_button.is_valid(): style_button.call(repay_button)
|
||||
_loan_box.add_child(repay_button)
|
||||
repay_button.pressed.connect(submit_repayment.bind(false))
|
||||
repay_all_button = Button.new()
|
||||
repay_all_button.name = "LoanRepayAllButton"
|
||||
repay_all_button.text = "元本を完済"
|
||||
if style_button.is_valid(): style_button.call(repay_all_button)
|
||||
_loan_box.add_child(repay_all_button)
|
||||
repay_all_button.pressed.connect(submit_repayment.bind(true))
|
||||
_loan_result = _label()
|
||||
_loan_result.name = "LoanRepaymentResult"
|
||||
|
||||
|
||||
func _loan_report() -> Dictionary:
|
||||
if _runtime == null or not _runtime.has_method(&"get_loan_principal_report"): return {}
|
||||
return _runtime.call(&"get_loan_principal_report")
|
||||
|
||||
|
||||
func _loan_row(report: Dictionary) -> Dictionary:
|
||||
for row: Dictionary in report.get(&"loans", []):
|
||||
if StringName(row[&"loan_id"]) == _selected_loan: return row
|
||||
return {}
|
||||
|
||||
|
||||
func _refresh_repayment_controls() -> void:
|
||||
if loan_option == null: return
|
||||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||||
var instance: int = queue.get_instance_id() if queue != null else 0
|
||||
if instance != _loan_queue_instance:
|
||||
_loan_queue_instance = instance
|
||||
_loan_pending_command = 0
|
||||
_loan_result.text = ""
|
||||
loan_amount.text = ""
|
||||
var report := _loan_report()
|
||||
var loans: Array = report.get(&"loans", [])
|
||||
_loan_box.visible = not loans.is_empty()
|
||||
if not loan_option.get_popup().visible:
|
||||
loan_option.clear()
|
||||
var selection := -1
|
||||
for index: int in loans.size():
|
||||
var row: Dictionary = loans[index]
|
||||
var remaining := int(row[&"outstanding_principal"])
|
||||
loan_option.add_item("借入 %d · %s" % [index + 1, "完済" if remaining == 0 else "残元本 %dG" % remaining])
|
||||
loan_option.set_item_metadata(index, row[&"loan_id"])
|
||||
if StringName(row[&"loan_id"]) == _selected_loan: selection = index
|
||||
if selection < 0 and not loans.is_empty():
|
||||
selection = 0
|
||||
_selected_loan = StringName(loans[0][&"loan_id"])
|
||||
if selection >= 0: loan_option.select(selection)
|
||||
var row := _loan_row(report)
|
||||
var remaining := int(row.get(&"outstanding_principal", 0))
|
||||
var cash := int(report.get(&"cash", 0))
|
||||
var waiting := not (row.get(&"pending_command_ids", []) as Array).is_empty()
|
||||
var allowed := bool(report.get(&"available", false)) and bool(report.get(&"ledger_valid", false)) and not bool(report.get(&"terminal", false)) and remaining > 0 and not waiting
|
||||
var text := loan_amount.text.strip_edges()
|
||||
var amount := int(text) if text.is_valid_int() else 0
|
||||
repay_button.disabled = not allowed or amount <= 0 or amount > remaining or amount > cash
|
||||
repay_all_button.disabled = not allowed or remaining > cash
|
||||
loan_amount.editable = allowed
|
||||
if not bool(report.get(&"available", false)) or not bool(report.get(&"ledger_valid", false)):
|
||||
_loan_hint.text = "返済の帳簿を確認できません。"
|
||||
elif bool(report.get(&"terminal", false)):
|
||||
_loan_hint.text = "運営は終了しました。借入の記録を確認できます。"
|
||||
elif loans.is_empty():
|
||||
_loan_hint.text = "借入はありません。"
|
||||
elif waiting:
|
||||
_loan_hint.text = "返済を受理済みです。次の時間更新で実行し、手持ちと残元本を再確認します。"
|
||||
elif remaining == 0:
|
||||
_loan_hint.text = "この借入は完済しています。"
|
||||
else:
|
||||
_loan_hint.text = "手持ち %dGから元本を返済します。残元本 %dG。返済額は日次経費に含まれます。" % [cash, remaining]
|
||||
if remaining > cash: _loan_hint.text += "\n完済には手持ちが不足しています。"
|
||||
if not text.is_empty() and repay_button.disabled: _loan_hint.text += "\n残元本と手持ちを超えない正の整数額を入力してください。"
|
||||
|
||||
|
||||
func submit_repayment(full: bool = false) -> void:
|
||||
var report := _loan_report()
|
||||
var row := _loan_row(report)
|
||||
var remaining := int(row.get(&"outstanding_principal", 0))
|
||||
var text := loan_amount.text.strip_edges()
|
||||
var amount := remaining if full else (int(text) if text.is_valid_int() else 0)
|
||||
if not bool(report.get(&"ledger_valid", false)) or bool(report.get(&"terminal", false)) or row.is_empty() or not (row.get(&"pending_command_ids", []) as Array).is_empty() or amount <= 0 or amount > remaining or amount > int(report.get(&"cash", 0)):
|
||||
_set_repayment_result("返済できません。手持ち・残元本・返済待ちを確認してください。", false)
|
||||
_refresh_repayment_controls()
|
||||
return
|
||||
var key := StringName("ui.loan.repay.%d" % int(report[&"next_command_id"]))
|
||||
var payload := {&"loan_id": _selected_loan, &"amount": amount}
|
||||
var result: Variant
|
||||
if _audio_submitter.is_valid():
|
||||
result = _audio_submitter.call(&"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key)
|
||||
else:
|
||||
result = _runtime.call(&"submit_command", &"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key)
|
||||
var after := _loan_row(_loan_report())
|
||||
if result.is_success() and (after.get(&"pending_command_ids", []) as Array).has(int(result.command_id)):
|
||||
_loan_pending_command = int(result.command_id)
|
||||
_set_repayment_result("返済を受理しました。実行時に手持ちと残元本を確認します。", true)
|
||||
else:
|
||||
_show_repayment_result(result)
|
||||
_refresh_repayment_controls()
|
||||
|
||||
|
||||
func _show_repayment_result(result: Variant) -> void:
|
||||
if result.is_success():
|
||||
_set_repayment_result("元本 %dGを返済しました。残元本 %dG。" % [int(result.details.get(&"paid_principal", 0)), int(result.details.get(&"outstanding_principal", 0))], true)
|
||||
loan_amount.text = ""
|
||||
else:
|
||||
_set_repayment_result("返済を実行できませんでした。手持ちと残元本を確認してください。", false)
|
||||
|
||||
|
||||
func _on_repayment_completed(result: Variant) -> void:
|
||||
if int(result.command_id) != _loan_pending_command: return
|
||||
_loan_pending_command = 0
|
||||
_show_repayment_result(result)
|
||||
_refresh_repayment_controls()
|
||||
|
||||
|
||||
func _set_repayment_result(message: String, success: bool) -> void:
|
||||
_loan_result.text = message
|
||||
LayerSkin.register_status_text(_loan_result, success, LayerSkin.active_layer(self))
|
||||
status_requested.emit(message, success)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
extends Node
|
||||
|
||||
## Two arrangements share one sample clock; changing layer never restarts music.
|
||||
const FRONT_PATH := "res://assets/audio/bgm/tree_theme_front_candidate.wav"
|
||||
const BACK_PATH := "res://assets/audio/bgm/tree_theme_back_candidate.wav"
|
||||
const CROSSFADE_SECONDS := 1.25
|
||||
|
||||
var mix := 0.0
|
||||
var target_mix := 0.0
|
||||
var playback_starts := 0
|
||||
var startup_error := ""
|
||||
var _start_mix := 0.0
|
||||
var _elapsed := CROSSFADE_SECONDS
|
||||
var _player: AudioStreamPlayer
|
||||
var _synchronized: AudioStreamSynchronized
|
||||
var _closed := false
|
||||
|
||||
|
||||
func configure(initial_layer: int) -> void:
|
||||
mix = float(clampi(initial_layer, 0, 1))
|
||||
target_mix = mix
|
||||
_start_mix = mix
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
var front := ResourceLoader.load(FRONT_PATH, "AudioStreamWAV", ResourceLoader.CACHE_MODE_IGNORE) as AudioStreamWAV
|
||||
var back := ResourceLoader.load(BACK_PATH, "AudioStreamWAV", ResourceLoader.CACHE_MODE_IGNORE) as AudioStreamWAV
|
||||
if front == null or back == null:
|
||||
startup_error = "MISSING_PAIRED_BGM"
|
||||
return
|
||||
if front.format != AudioStreamWAV.FORMAT_16_BITS or back.format != front.format or not front.stereo or not back.stereo or front.mix_rate != back.mix_rate or front.data.size() != back.data.size() or front.data.is_empty():
|
||||
startup_error = "INCOMPATIBLE_PAIRED_BGM"
|
||||
return
|
||||
for stream: AudioStreamWAV in [front, back]:
|
||||
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
stream.loop_begin = 0
|
||||
stream.loop_end = int(stream.data.size() / 4.0)
|
||||
_synchronized = AudioStreamSynchronized.new()
|
||||
_synchronized.stream_count = 2
|
||||
_synchronized.set_sync_stream(0, front)
|
||||
_synchronized.set_sync_stream(1, back)
|
||||
_apply_mix()
|
||||
_player = AudioStreamPlayer.new()
|
||||
_player.name = "PairedBGMPlayer"
|
||||
_player.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_player.bus = &"Master"
|
||||
_player.stream = _synchronized
|
||||
add_child(_player)
|
||||
_player.play()
|
||||
playback_starts += 1
|
||||
|
||||
|
||||
func set_layer(layer: int) -> void:
|
||||
if _closed:
|
||||
return
|
||||
var destination := float(clampi(layer, 0, 1))
|
||||
if destination == target_mix:
|
||||
return
|
||||
_start_mix = mix
|
||||
target_mix = destination
|
||||
_elapsed = 0.0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _closed or _elapsed >= CROSSFADE_SECONDS or not is_finite(delta) or delta <= 0.0:
|
||||
return
|
||||
_elapsed = minf(CROSSFADE_SECONDS, _elapsed + delta)
|
||||
mix = lerpf(_start_mix, target_mix, _elapsed / CROSSFADE_SECONDS)
|
||||
_apply_mix()
|
||||
|
||||
|
||||
func _apply_mix() -> void:
|
||||
if _synchronized == null:
|
||||
return
|
||||
var front_gain := cos(mix * PI / 2.0)
|
||||
var back_gain := sin(mix * PI / 2.0)
|
||||
_synchronized.set_sync_stream_volume(0, linear_to_db(front_gain) if front_gain > 0.0001 else -80.0)
|
||||
_synchronized.set_sync_stream_volume(1, linear_to_db(back_gain) if back_gain > 0.0001 else -80.0)
|
||||
|
||||
|
||||
func capture_owned_targets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for entry: Array in [[&"bgm_owner", self], [&"bgm_player", _player], [&"bgm_synchronized", _synchronized]]:
|
||||
if is_instance_valid(entry[1]):
|
||||
result.append({&"role": &"bgm", &"kind": entry[0], &"weak": weakref(entry[1]), &"instance_id": entry[1].get_instance_id()})
|
||||
if _synchronized != null:
|
||||
for index: int in range(2):
|
||||
var stream := _synchronized.get_sync_stream(index)
|
||||
if stream != null:
|
||||
result.append({&"role": &"front" if index == 0 else &"back", &"kind": &"bgm_stream", &"weak": weakref(stream), &"instance_id": stream.get_instance_id()})
|
||||
if is_instance_valid(_player) and _player.has_stream_playback():
|
||||
var playback := _player.get_stream_playback()
|
||||
result.append({&"role": &"bgm", &"kind": &"bgm_playback", &"weak": weakref(playback), &"instance_id": playback.get_instance_id()})
|
||||
return result
|
||||
|
||||
|
||||
func prepare_owner_shutdown() -> void:
|
||||
_closed = true
|
||||
set_process(false)
|
||||
if is_instance_valid(_player):
|
||||
_player.stop()
|
||||
_player.stream = null
|
||||
_synchronized = null
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
prepare_owner_shutdown()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cve5judsgtgcy
|
||||
@@ -16,6 +16,22 @@ static func pose(mode: StringName, frame: int, variant: int, staff: bool) -> Arr
|
||||
var work := mode in [&"work", &"clean", &"eat"]
|
||||
var stride := [0, 1, 2, 1, 0, -1, -2, -1][phase] as int
|
||||
var bob := -1 if walk and phase in [2, 6] else 0
|
||||
if mode == &"standard_room_sleep":
|
||||
# General-room presentation only: same16x18 actor grid and original skin.
|
||||
# Breathing follows existing visual frame; no Clock, guest or service change.
|
||||
var breathing := -1 if phase in [2, 3, 4] else 0
|
||||
mark(marks, Rect2(2, 14, 13, 2), INK)
|
||||
mark(marks, Rect2(2, 11, 5, 4), Color("eee5c9"))
|
||||
mark(marks, Rect2(3, 12, 4, 3), skin)
|
||||
mark(marks, Rect2(2, 12, 2, 3), INK)
|
||||
mark(marks, Rect2(5, 13, 2, 1), INK)
|
||||
mark(marks, Rect2(7, 11 + breathing, 8, 5 - breathing), INK)
|
||||
mark(marks, Rect2(8, 12 + breathing, 6, 3 - breathing), Color("608d84"))
|
||||
mark(marks, Rect2(8, 12 + breathing, 6, 1), Color("eee5c9"))
|
||||
mark(marks, Rect2(9, 14 + breathing, 4, 1), Color("719d94"))
|
||||
mark(marks, Rect2(6, 13 + breathing, 2, 1), skin)
|
||||
mark(marks, Rect2(14, 14, 1, 2), INK)
|
||||
return marks
|
||||
if mode in [&"sleep", &"collapsed"]:
|
||||
mark(marks, Rect2(2, 14, 12, 2), INK)
|
||||
mark(marks, Rect2(3, 12, 4, 3), skin)
|
||||
|
||||
@@ -4,6 +4,8 @@ extends RefCounted
|
||||
## Small, code-native room furnishings. Coordinates use the same pixel size as
|
||||
## Kenney's 16 px sprites. This renderer never changes simulation state.
|
||||
const Layout := preload("res://scripts/ui/room_presentation.gd")
|
||||
const StandardRooms := preload("res://scripts/ui/standard_room_art.gd")
|
||||
const InfirmaryRooms := preload("res://scripts/ui/infirmary_room_art.gd")
|
||||
const INK := Color("34413b")
|
||||
const WOOD := Color("8e6348")
|
||||
const WOOD_LIGHT := Color("c39665")
|
||||
@@ -16,7 +18,7 @@ static func tile(canvas: CanvasItem, texture: Texture2D, index: int, target: Rec
|
||||
canvas.draw_texture_rect_region(texture, target, Rect2((index % 12) * 16, (index / 12) * 16, 16, 16), tint)
|
||||
|
||||
|
||||
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0, presentation: Dictionary = {}) -> void:
|
||||
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0, presentation: Dictionary = {}, visual_level: int = 1) -> void:
|
||||
var p := pixel
|
||||
if id in [&"guest_stairs", &"service_stairs"]:
|
||||
stairs(canvas, rect, p, id == &"service_stairs")
|
||||
@@ -65,12 +67,21 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
var lamp_x := mid if w > 32*p else rect.end.x-4*p
|
||||
canvas.draw_rect(Rect2(lamp_x-p, top+3*p, 2*p, 3*p), WOOD)
|
||||
canvas.draw_rect(Rect2(lamp_x-2*p, top+5*p, 4*p, 2*p), Color("edc97c"))
|
||||
if id == &"standard_room":
|
||||
StandardRooms.wall_trim(canvas, rect, p, back, visual_level)
|
||||
match id:
|
||||
&"reception":
|
||||
counter(canvas, Vector2(mid-9*p, floor_y-6*p), 18*p, p)
|
||||
canvas.draw_rect(Rect2(mid-6*p, floor_y-8*p, 4*p, 2*p), CREAM)
|
||||
canvas.draw_rect(Rect2(mid+4*p, floor_y-8*p, 2*p, 2*p), Color("d6ad52"))
|
||||
&"standard_room", &"premium_room", &"staff_dormitory", &"adult_private_room", &"infirmary":
|
||||
&"standard_room":
|
||||
for at: Vector2 in Layout.slots(rect, p, id):
|
||||
var bed_at: Vector2 = at + Vector2(-7, -9)*p
|
||||
bed(canvas, bed_at, p, Color("709b94") if not dark else Color("a37b91"), 13)
|
||||
StandardRooms.bed_trim(canvas, bed_at, p, back, visual_level)
|
||||
&"infirmary":
|
||||
InfirmaryRooms.draw_room(canvas,rect,p,back,visual_level,dungeon)
|
||||
&"premium_room", &"staff_dormitory", &"adult_private_room":
|
||||
for at: Vector2 in Layout.slots(rect, p, id):
|
||||
bed(canvas, at + Vector2(-7, -9)*p, p, Color("709b94") if not dark else Color("a37b91"), 13)
|
||||
&"cafeteria", &"tavern":
|
||||
|
||||
@@ -64,6 +64,12 @@ func prepare(runtime: Variant, clock: Variant, tree: SceneTree) -> bool:
|
||||
&"candidate_id": row[0], &"facility_id": row[1], &"current_cell": row[2],
|
||||
}):
|
||||
return false
|
||||
if not _command(&"hire_staff_candidate", {&"candidate_id": &"staff_doran", &"facility_id": facility_ids[&"kitchen"], &"current_cell": Vector3i(2, 0, 1)}):
|
||||
return false
|
||||
if not _command(&"set_staff_work_assignment", {&"staff_id": &"staff_doran", &"facility_id": facility_ids[&"kitchen"], &"template_id": &"DAY"}):
|
||||
return false
|
||||
if not _command(&"set_facility_conditions", {&"facility_id": facility_ids[&"cafeteria"], &"conditions": {&"related_facility_id": facility_ids[&"kitchen"]}}):
|
||||
return false
|
||||
# A small explicitly seeded pantry is part of the disposable scenario; its
|
||||
# stacks are real and get consumed by the same service pipeline as gameplay.
|
||||
var cafeteria: Variant = _runtime.facility_service.get_instance(facility_ids[&"cafeteria"])
|
||||
|
||||
@@ -29,6 +29,7 @@ var result_label: Label
|
||||
var selected_staff_id: StringName = &""
|
||||
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _style_button: Callable
|
||||
var _staff_rows: Array[Dictionary] = []
|
||||
var _facility_rows: Array[Dictionary] = []
|
||||
@@ -147,6 +148,9 @@ func refresh() -> void:
|
||||
_style(button)
|
||||
roster_list.add_child(button)
|
||||
roster_buttons[staff_id] = button
|
||||
# Existing buttons may have been created in hire order. Match the
|
||||
# authoritative report order on every refresh, including after Load.
|
||||
roster_list.move_child(button, live_ids.size() - 1)
|
||||
button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""]
|
||||
button.tooltip_text = "施設へドラッグして配属(選択中の勤務帯を適用)\n%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
|
||||
for raw_id: Variant in roster_buttons.keys():
|
||||
@@ -169,6 +173,19 @@ func refresh() -> void:
|
||||
_refresh_editor()
|
||||
|
||||
|
||||
## Reset optional presentation without selecting the first roster row.
|
||||
## Opening the roster still uses refresh() to choose its normal default.
|
||||
func clear_selection() -> void:
|
||||
selected_staff_id = &""
|
||||
_draft_dirty = false
|
||||
if not _built:
|
||||
return
|
||||
result_label.text = ""
|
||||
for raw_id: Variant in roster_buttons:
|
||||
(roster_buttons[raw_id] as Button).set_pressed_no_signal(false)
|
||||
editor_panel.hide()
|
||||
|
||||
|
||||
func select_staff(staff_id: StringName) -> void:
|
||||
selected_staff_id = staff_id
|
||||
_draft_dirty = false
|
||||
@@ -273,7 +290,7 @@ func submit_assignment() -> void:
|
||||
return
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", ASSIGNMENT_COMMAND, {
|
||||
var result: Variant = _submit_ui_command(ASSIGNMENT_COMMAND, {
|
||||
&"staff_id": selected_staff_id,
|
||||
&"facility_id": _draft_facility_id,
|
||||
&"template_id": _draft_template_id,
|
||||
@@ -489,3 +506,17 @@ func assign_from_drop(data: Dictionary, facility_id: StringName) -> void:
|
||||
_draft_template_id = StringName(data.get(&"template_id", &"DAY"))
|
||||
_draft_dirty = true
|
||||
submit_assignment()
|
||||
|
||||
|
||||
func configure_audio(submitter: Callable) -> void:
|
||||
_audio_submitter = submitter
|
||||
|
||||
|
||||
func _submit_ui_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if _audio_submitter.is_valid():
|
||||
return _audio_submitter.call(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
class_name StandardRoomArt
|
||||
extends RefCounted
|
||||
|
||||
## Provisional visual-only Lv1..3 study. Preserve the existing two bed anchors,
|
||||
## footprint and operation overlays. These are cosmetic pixels, not new game
|
||||
## costs, capacity, quality or upgrade rules. Level1 draws no added rectangles.
|
||||
## Existing 16px furniture palette is retained. Inspected Kenney references and
|
||||
## exact CC0 records are pinned; no narrow two-bed replacement was selected.
|
||||
const BED_TRIMS := {
|
||||
"2": [
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
13,
|
||||
1
|
||||
],
|
||||
"front": "c39665",
|
||||
"back": "8fa5a2"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"size": [
|
||||
2,
|
||||
8
|
||||
],
|
||||
"front": "a87852",
|
||||
"back": "698783"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
5,
|
||||
4
|
||||
],
|
||||
"size": [
|
||||
7,
|
||||
1
|
||||
],
|
||||
"front": "e5dac0",
|
||||
"back": "b8ccc2"
|
||||
}
|
||||
],
|
||||
"3": [
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
13,
|
||||
1
|
||||
],
|
||||
"front": "c39665",
|
||||
"back": "8fa5a2"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"size": [
|
||||
11,
|
||||
1
|
||||
],
|
||||
"front": "eee5c9",
|
||||
"back": "c3d1c5"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"size": [
|
||||
2,
|
||||
8
|
||||
],
|
||||
"front": "a87852",
|
||||
"back": "698783"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
5,
|
||||
6
|
||||
],
|
||||
"size": [
|
||||
7,
|
||||
1
|
||||
],
|
||||
"front": "eee5c9",
|
||||
"back": "c3d1c5"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
7,
|
||||
3
|
||||
],
|
||||
"size": [
|
||||
3,
|
||||
3
|
||||
],
|
||||
"front": "d6ad52",
|
||||
"back": "93b6ae"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
8,
|
||||
4
|
||||
],
|
||||
"size": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"front": "fff0d6",
|
||||
"back": "e6edda"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
static func bed_trim(canvas: CanvasItem, at: Vector2, p: float, back: bool, level: int) -> void:
|
||||
var commands: Array = BED_TRIMS.get(str(clampi(level, 1, 3)), [])
|
||||
for command: Dictionary in commands:
|
||||
var offset: Array = command["at"]
|
||||
var dimensions: Array = command["size"]
|
||||
var tone: Color = Color(String(command["back"] if back else command["front"]))
|
||||
canvas.draw_rect(Rect2(at + Vector2(float(offset[0]), float(offset[1]))*p,
|
||||
Vector2(float(dimensions[0]), float(dimensions[1]))*p), tone)
|
||||
|
||||
|
||||
static func wall_trim(canvas: CanvasItem, rect: Rect2, p: float, back: bool, level: int) -> void:
|
||||
if level < 2:
|
||||
return
|
||||
var tone: Color = Color("8fa5a2") if back else Color("c39665")
|
||||
canvas.draw_rect(Rect2(rect.position + Vector2(1, 2)*p, Vector2(rect.size.x - 2*p, p)), tone)
|
||||
if level >= 3:
|
||||
for offset: int in [-2, 0, 2]:
|
||||
canvas.draw_rect(Rect2(Vector2(rect.get_center().x + offset*p, rect.position.y + 3*p), Vector2(p, p)), tone)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bophxjq1fck3q
|
||||
@@ -86,6 +86,7 @@ var selected_kitchen_id: StringName = &""
|
||||
var selected_tab: StringName = &"purchase"
|
||||
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _quote: Dictionary = {}
|
||||
@@ -282,7 +283,11 @@ func _refresh_purchase() -> void:
|
||||
if catalog.is_empty() or warehouse.is_empty():
|
||||
purchase_quote_label.text = "受入倉庫と品目を選んでください。"
|
||||
return
|
||||
purchase_quote_label.text = "単価 %dG / %s\n合計 %dG · 購入後 %dG" % [catalog.get(&"unit_price", 0), catalog.get(&"unit_name", "個"), _quote.get(&"total_cost", 0), _quote.get(&"cash_after", _report.get(&"cash", 0))]
|
||||
if _quote.has(&"purchase_quote"):
|
||||
var frozen: Dictionary = _quote[&"purchase_quote"]
|
||||
purchase_quote_label.text = "基準単価 %dG / %s\n基準合計 %dG · ミミの仕入れ20%%割引\n合計 %dG · 購入後 %dG" % [frozen[&"base_unit_price"], catalog.get(&"unit_name", "個"), frozen[&"base_total"], frozen[&"effective_total"], _quote.get(&"cash_after", _report.get(&"cash", 0))]
|
||||
else:
|
||||
purchase_quote_label.text = "単価 %dG / %s\n合計 %dG · 購入後 %dG" % [catalog.get(&"unit_price", 0), catalog.get(&"unit_name", "個"), _quote.get(&"total_cost", 0), _quote.get(&"cash_after", _report.get(&"cash", 0))]
|
||||
if not bool(_quote.get(&"ok", false)):
|
||||
purchase_quote_label.text += "\n" + _reason_text(_quote.get(&"reason", &"UNAVAILABLE"))
|
||||
|
||||
@@ -323,7 +328,11 @@ func _refresh_auto_purchase(warehouse: Dictionary) -> void:
|
||||
if int(row.get(&"deficit", 0)) > 0 and not quote.is_empty():
|
||||
auto_purchase_status.text += "\n不足 %d · 今の購入見積 %dG" % [row[&"deficit"], quote.get(&"total_cost", 0)]
|
||||
if row.get(&"last_result", &"") == &"PURCHASED":
|
||||
auto_purchase_status.text += "\n前回入庫 %d個 · 代金 %dG(単価 %dG)" % [row[&"last_quantity"], row[&"last_total_cost"], row[&"last_unit_price"]]
|
||||
if row.has(&"last_quote"):
|
||||
var frozen: Dictionary = row[&"last_quote"]
|
||||
auto_purchase_status.text += "\n前回入庫 %d個 · 基準合計 %dG · ミミの仕入れ20%%割引 · 代金 %dG" % [row[&"last_quantity"], frozen[&"base_total"], frozen[&"effective_total"]]
|
||||
else:
|
||||
auto_purchase_status.text += "\n前回入庫 %d個 · 代金 %dG(単価 %dG)" % [row[&"last_quantity"], row[&"last_total_cost"], row[&"last_unit_price"]]
|
||||
if _auto_purchase_drafts.has(_auto_purchase_key()):
|
||||
auto_purchase_status.text += "\n未保存の変更です。設定を保存すると反映します。"
|
||||
auto_purchase_status.text += "\n在庫が減ると目標まで不足分を購入します。料金は手動購入と同じです。"
|
||||
@@ -430,7 +439,7 @@ func _submit(type: StringName, payload: Dictionary) -> void:
|
||||
var context := {&"type": type, &"payload": payload.duplicate(true)}
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", type, payload, &"player", StringName(payload.get(&"facility_id", payload.get(&"warehouse_id", &""))), 75, -1, key)
|
||||
var result: Variant = _submit_ui_command(type, payload, &"player", StringName(payload.get(&"facility_id", payload.get(&"warehouse_id", &""))), 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
@@ -643,9 +652,9 @@ func _button(value: String, node_name: String, callback: Callable) -> Button:
|
||||
button.text = value
|
||||
button.custom_minimum_size.y = 30
|
||||
button.add_theme_font_size_override("font_size", 13)
|
||||
button.pressed.connect(callback)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(button)
|
||||
button.pressed.connect(callback)
|
||||
return button
|
||||
|
||||
|
||||
@@ -670,3 +679,17 @@ func _spin(node_name: String, minimum: int, value: int) -> SpinBox:
|
||||
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spin.custom_minimum_size.y = 30
|
||||
return spin
|
||||
|
||||
|
||||
func configure_audio(submitter: Callable) -> void:
|
||||
_audio_submitter = submitter
|
||||
|
||||
|
||||
func _submit_ui_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if _audio_submitter.is_valid():
|
||||
return _audio_submitter.call(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
class_name UIAudioFeedback
|
||||
extends Node
|
||||
|
||||
## Presentation-only feedback. No Runtime snapshot/RNG/command mutation.
|
||||
signal cue_requested(role: StringName, correlation: Dictionary)
|
||||
|
||||
const UI_BUS := &"TheTreeUI"
|
||||
const SETTINGS_VERSION := 1
|
||||
const DEFAULT_SETTINGS := {&"master_volume": 1.0, &"ui_volume": 0.5, &"muted": false}
|
||||
const CUES := {
|
||||
&"click": "res://assets/audio/ui/click_001.ogg",
|
||||
&"success": "res://assets/audio/ui/confirmation_001.ogg",
|
||||
&"refusal": "res://assets/audio/ui/error_008.ogg",
|
||||
&"notification": "res://assets/audio/ui/bong_001.ogg",
|
||||
}
|
||||
const MAX_POLYPHONY_PER_CUE := 2
|
||||
const UI_LIMITER_CEILING_DB := -1.0
|
||||
|
||||
var playback_enabled := true
|
||||
var settings: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||
var settings_writable := true
|
||||
var settings_reason: StringName = &""
|
||||
var _settings_path := ""
|
||||
var _settings_fingerprint := ""
|
||||
var _settings_config := ConfigFile.new()
|
||||
var _runtime: Variant
|
||||
var _epoch := 0
|
||||
var _nonce := 0
|
||||
var _active_submissions: Array[Dictionary] = []
|
||||
var _armed: Dictionary = {}
|
||||
var _seen_foregrounds: Dictionary = {}
|
||||
var _players: Dictionary = {}
|
||||
var _owner_shutting_down := false
|
||||
|
||||
|
||||
func configure(runtime: Variant, settings_path := "", enable_playback := true) -> void:
|
||||
bind_runtime(runtime)
|
||||
playback_enabled = enable_playback
|
||||
_settings_path = settings_path
|
||||
read_settings()
|
||||
if is_inside_tree():
|
||||
_apply_bus_settings()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_ensure_ui_bus()
|
||||
for role: StringName in CUES:
|
||||
if not ResourceLoader.exists(CUES[role], "AudioStream"):
|
||||
continue
|
||||
var stream := load(CUES[role]) as AudioStreamOggVorbis
|
||||
if stream == null:
|
||||
continue
|
||||
stream = stream.duplicate() as AudioStreamOggVorbis
|
||||
stream.loop = false
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.name = "UICue_%s" % role
|
||||
player.stream = stream
|
||||
player.bus = UI_BUS
|
||||
player.max_polyphony = MAX_POLYPHONY_PER_CUE
|
||||
player.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
add_child(player)
|
||||
_players[role] = player
|
||||
_apply_bus_settings()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_disconnect_runtime()
|
||||
_armed.clear()
|
||||
_active_submissions.clear()
|
||||
|
||||
|
||||
func prepare_owner_shutdown() -> void:
|
||||
_owner_shutting_down = true
|
||||
_disconnect_runtime()
|
||||
_armed.clear()
|
||||
_active_submissions.clear()
|
||||
_seen_foregrounds.clear()
|
||||
for player: AudioStreamPlayer in _players.values():
|
||||
player.stop()
|
||||
player.stream = null
|
||||
_players.clear()
|
||||
|
||||
|
||||
func bind_runtime(runtime: Variant) -> void:
|
||||
_disconnect_runtime()
|
||||
_runtime = runtime
|
||||
begin_loaded_epoch()
|
||||
if is_instance_valid(_runtime) and _runtime.has_signal(&"command_completed"):
|
||||
if not _runtime.is_connected(&"command_completed", _on_command_completed):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
|
||||
|
||||
func _disconnect_runtime() -> void:
|
||||
if is_instance_valid(_runtime) and _runtime.has_signal(&"command_completed"):
|
||||
if _runtime.is_connected(&"command_completed", _on_command_completed):
|
||||
_runtime.disconnect(&"command_completed", _on_command_completed)
|
||||
|
||||
|
||||
func begin_loaded_epoch(current_foreground: Variant = null) -> void:
|
||||
_epoch += 1
|
||||
_armed.clear()
|
||||
_active_submissions.clear()
|
||||
_seen_foregrounds.clear()
|
||||
for player: AudioStreamPlayer in _players.values():
|
||||
player.stop()
|
||||
observe_foreground(current_foreground, true)
|
||||
|
||||
|
||||
func submit_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if not is_instance_valid(_runtime) or not _runtime.has_method(&"submit_command"):
|
||||
return null
|
||||
_nonce += 1
|
||||
var intent := {&"epoch": _epoch, &"nonce": _nonce, &"consumed": false, &"buffer": {}}
|
||||
# Paused Runtime emits completion before returning. Buffer before the call.
|
||||
_active_submissions.append(intent)
|
||||
var result: Variant = _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
_active_submissions.erase(intent)
|
||||
if not result is CommandResult or result.is_replay or int(intent[&"epoch"]) != _epoch:
|
||||
return result
|
||||
# Enqueue/validation failure is authoritative, even if an ID was rejected.
|
||||
if not result.is_success():
|
||||
_deliver_outcome(result, intent)
|
||||
return result
|
||||
var command_id: int = result.command_id
|
||||
var completed: Variant = intent[&"buffer"].get(command_id)
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if completed == null and queue != null and queue.has_method(&"get_processed_result"):
|
||||
completed = queue.call(&"get_processed_result", command_id)
|
||||
if completed is CommandResult:
|
||||
_deliver_outcome(completed, intent)
|
||||
elif _is_pending(queue, command_id):
|
||||
_armed[command_id] = intent
|
||||
# Unclassified acceptance is not evidence of successful completion.
|
||||
return result
|
||||
|
||||
|
||||
func _is_pending(queue: Variant, command_id: int) -> bool:
|
||||
if queue == null or command_id <= 0 or not queue.has_method(&"create_snapshot"):
|
||||
return false
|
||||
var snapshot: Dictionary = queue.call(&"create_snapshot")
|
||||
for command: Dictionary in snapshot.get("pending", []):
|
||||
if int(command.get("command_id", 0)) == command_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if not result is CommandResult:
|
||||
return
|
||||
var command_id: int = result.command_id
|
||||
if _armed.has(command_id):
|
||||
var intent: Dictionary = _armed[command_id]
|
||||
_armed.erase(command_id)
|
||||
_deliver_outcome(result, intent)
|
||||
for intent: Dictionary in _active_submissions:
|
||||
if command_id > 0:
|
||||
intent[&"buffer"][command_id] = result
|
||||
|
||||
|
||||
func _deliver_outcome(result: CommandResult, intent: Dictionary) -> void:
|
||||
if bool(intent[&"consumed"]) or int(intent[&"epoch"]) != _epoch:
|
||||
return
|
||||
intent[&"consumed"] = true
|
||||
if result.is_replay:
|
||||
return
|
||||
if result.is_success() and (
|
||||
bool(result.details.get(&"unchanged", false))
|
||||
or bool(result.details.get(&"noop", false))
|
||||
or bool(result.details.get(&"no_op", false))
|
||||
):
|
||||
return
|
||||
_request_cue(&"success" if result.is_success() else &"refusal", {
|
||||
&"epoch": _epoch, &"intent": intent[&"nonce"], &"command_id": result.command_id,
|
||||
})
|
||||
|
||||
|
||||
func reject_local(reason: StringName) -> void:
|
||||
_nonce += 1
|
||||
_request_cue(&"refusal", {&"epoch": _epoch, &"intent": _nonce, &"reason": reason})
|
||||
|
||||
|
||||
func observe_foreground(instance: Variant, silent := false) -> void:
|
||||
if instance == null or int(instance.state) != 2:
|
||||
return
|
||||
var instance_id := StringName(instance.event_instance_id)
|
||||
if instance_id.is_empty() or _seen_foregrounds.has(instance_id):
|
||||
return
|
||||
_seen_foregrounds[instance_id] = true
|
||||
if not silent:
|
||||
_request_cue(&"notification", {&"epoch": _epoch, &"event_instance_id": instance_id})
|
||||
|
||||
|
||||
func bind_button(button: BaseButton) -> void:
|
||||
var callback := _on_button_pressed.bind(button)
|
||||
if not button.pressed.is_connected(callback):
|
||||
button.pressed.connect(callback)
|
||||
if button is OptionButton or button is MenuButton:
|
||||
var popup: PopupMenu = button.get_popup()
|
||||
if not popup.id_pressed.is_connected(_on_popup_item_pressed):
|
||||
popup.id_pressed.connect(_on_popup_item_pressed)
|
||||
|
||||
|
||||
func _on_button_pressed(button: BaseButton) -> void:
|
||||
if is_instance_valid(button) and not button.disabled:
|
||||
request_click()
|
||||
|
||||
|
||||
func _on_popup_item_pressed(_id: int) -> void:
|
||||
request_click()
|
||||
|
||||
|
||||
func request_click() -> void:
|
||||
_request_cue(&"click", {&"epoch": _epoch})
|
||||
|
||||
|
||||
func _request_cue(role: StringName, correlation: Dictionary) -> void:
|
||||
if _owner_shutting_down:
|
||||
return
|
||||
if bool(settings[&"muted"]) or float(settings[&"master_volume"]) <= 0.0 or float(settings[&"ui_volume"]) <= 0.0:
|
||||
return
|
||||
cue_requested.emit(role, correlation.duplicate(true))
|
||||
if playback_enabled and _players.has(role):
|
||||
(_players[role] as AudioStreamPlayer).play()
|
||||
|
||||
|
||||
func _ensure_ui_bus() -> void:
|
||||
if AudioServer.get_bus_index(UI_BUS) < 0:
|
||||
AudioServer.add_bus()
|
||||
AudioServer.set_bus_name(AudioServer.bus_count - 1, String(UI_BUS))
|
||||
AudioServer.set_bus_send(AudioServer.get_bus_index(UI_BUS), &"Master")
|
||||
var limiter := AudioEffectHardLimiter.new()
|
||||
limiter.ceiling_db = UI_LIMITER_CEILING_DB
|
||||
AudioServer.add_bus_effect(AudioServer.get_bus_index(UI_BUS), limiter)
|
||||
|
||||
|
||||
func _apply_bus_settings() -> void:
|
||||
_ensure_ui_bus()
|
||||
if bool(settings[&"muted"]) or float(settings[&"master_volume"]) == 0.0 or float(settings[&"ui_volume"]) == 0.0:
|
||||
for player: AudioStreamPlayer in _players.values():
|
||||
player.stop()
|
||||
for entry: Array in [[&"Master", &"master_volume"], [UI_BUS, &"ui_volume"]]:
|
||||
var index := AudioServer.get_bus_index(entry[0])
|
||||
if index < 0:
|
||||
continue
|
||||
var volume := float(settings[entry[1]])
|
||||
AudioServer.set_bus_volume_db(index, linear_to_db(volume) if volume > 0.0 else -80.0)
|
||||
AudioServer.set_bus_mute(index, bool(settings[&"muted"]) or volume == 0.0)
|
||||
|
||||
|
||||
func update_settings(value: Dictionary) -> bool:
|
||||
if not _valid_settings(value):
|
||||
return false
|
||||
settings = value.duplicate(true)
|
||||
_apply_bus_settings()
|
||||
return true
|
||||
|
||||
|
||||
func _valid_settings(value: Dictionary) -> bool:
|
||||
for key: StringName in [&"master_volume", &"ui_volume"]:
|
||||
var raw: Variant = value.get(key)
|
||||
if typeof(raw) not in [TYPE_INT, TYPE_FLOAT] or not is_finite(float(raw)) or float(raw) < 0.0 or float(raw) > 1.0:
|
||||
return false
|
||||
return typeof(value.get(&"muted")) == TYPE_BOOL
|
||||
|
||||
|
||||
func read_settings() -> void:
|
||||
settings = DEFAULT_SETTINGS.duplicate(true)
|
||||
settings_writable = true
|
||||
settings_reason = &""
|
||||
_settings_config = ConfigFile.new()
|
||||
_settings_fingerprint = ""
|
||||
if _settings_path.is_empty() or not FileAccess.file_exists(_settings_path):
|
||||
return
|
||||
_settings_fingerprint = FileAccess.get_sha256(_settings_path)
|
||||
if _settings_config.load(_settings_path) != OK:
|
||||
settings_writable = false
|
||||
settings_reason = &"UNREADABLE_SETTINGS"
|
||||
return
|
||||
var version: Variant = _settings_config.get_value("schema", "version", 0)
|
||||
if typeof(version) != TYPE_INT or version != SETTINGS_VERSION:
|
||||
settings_writable = false
|
||||
settings_reason = &"UNSUPPORTED_SETTINGS_VERSION"
|
||||
return
|
||||
var loaded := {}
|
||||
for key: StringName in DEFAULT_SETTINGS:
|
||||
loaded[key] = _settings_config.get_value("audio", String(key), DEFAULT_SETTINGS[key])
|
||||
if not _valid_settings(loaded):
|
||||
settings_writable = false
|
||||
settings_reason = &"INVALID_SETTINGS_VALUES"
|
||||
return
|
||||
settings = loaded
|
||||
|
||||
|
||||
func save_settings() -> Dictionary:
|
||||
if not settings_writable or _settings_path.is_empty():
|
||||
return {&"ok": false, &"reason": settings_reason if not settings_reason.is_empty() else &"NO_SETTINGS_PATH"}
|
||||
if not _valid_settings(settings):
|
||||
return {&"ok": false, &"reason": &"INVALID_SETTINGS_VALUES"}
|
||||
var current := FileAccess.get_sha256(_settings_path) if FileAccess.file_exists(_settings_path) else ""
|
||||
if current != _settings_fingerprint:
|
||||
return {&"ok": false, &"reason": &"SETTINGS_CHANGED_EXTERNALLY"}
|
||||
var target := ProjectSettings.globalize_path(_settings_path)
|
||||
var directory_error := DirAccess.make_dir_recursive_absolute(target.get_base_dir())
|
||||
if directory_error != OK and directory_error != ERR_ALREADY_EXISTS:
|
||||
return {&"ok": false, &"reason": &"SETTINGS_DIRECTORY_FAILED"}
|
||||
var temporary := "%s.tmp-%d-%d" % [target, OS.get_process_id(), Time.get_ticks_usec()]
|
||||
if FileAccess.file_exists(temporary):
|
||||
return {&"ok": false, &"reason": &"SETTINGS_TEMP_EXISTS"}
|
||||
_settings_config.set_value("schema", "version", SETTINGS_VERSION)
|
||||
for key: StringName in DEFAULT_SETTINGS:
|
||||
_settings_config.set_value("audio", String(key), settings[key])
|
||||
var error := _settings_config.save(temporary)
|
||||
if error != OK:
|
||||
if FileAccess.file_exists(temporary): DirAccess.remove_absolute(temporary)
|
||||
return {&"ok": false, &"reason": &"SETTINGS_WRITE_FAILED"}
|
||||
current = FileAccess.get_sha256(_settings_path) if FileAccess.file_exists(_settings_path) else ""
|
||||
if current != _settings_fingerprint:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return {&"ok": false, &"reason": &"SETTINGS_CHANGED_EXTERNALLY"}
|
||||
if DirAccess.rename_absolute(temporary, target) != OK:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return {&"ok": false, &"reason": &"SETTINGS_REPLACE_FAILED"}
|
||||
_settings_fingerprint = FileAccess.get_sha256(_settings_path)
|
||||
return {&"ok": true}
|
||||
@@ -0,0 +1 @@
|
||||
uid://u38heyutw0xp
|
||||
@@ -0,0 +1,171 @@
|
||||
extends RefCounted
|
||||
|
||||
## One close coordinator per SceneTree; owners remain alive while audio drains.
|
||||
## Runtime values, settings and shared AudioServer buses are never rewritten.
|
||||
const OWNER_META := &"_the_tree_ui_close_owners"
|
||||
const MAXIMUM_WAIT_US := 500000
|
||||
|
||||
|
||||
static func register_owner(tree: SceneTree, owner: Node) -> void:
|
||||
var state: Dictionary
|
||||
if tree.root.has_meta(OWNER_META):
|
||||
state = tree.root.get_meta(OWNER_META)
|
||||
else:
|
||||
state = {&"original_auto_accept_quit": tree.auto_accept_quit, &"owners": [], &"closing": false}
|
||||
tree.root.set_meta(OWNER_META, state)
|
||||
var owners: Array = state[&"owners"]
|
||||
for reference: WeakRef in owners:
|
||||
if reference.get_ref() == owner:
|
||||
return
|
||||
owners.append(weakref(owner))
|
||||
tree.auto_accept_quit = false
|
||||
|
||||
|
||||
static func unregister_owner(tree: SceneTree, owner: Node) -> void:
|
||||
if not tree.root.has_meta(OWNER_META):
|
||||
return
|
||||
var state: Dictionary = tree.root.get_meta(OWNER_META)
|
||||
var kept: Array = []
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var node: Variant = reference.get_ref()
|
||||
if is_instance_valid(node) and node != owner:
|
||||
kept.append(reference)
|
||||
state[&"owners"] = kept
|
||||
if kept.is_empty() and not bool(state[&"closing"]):
|
||||
tree.auto_accept_quit = bool(state[&"original_auto_accept_quit"])
|
||||
tree.root.remove_meta(OWNER_META)
|
||||
|
||||
|
||||
static func capture_and_release(audio: Node) -> Array[Dictionary]:
|
||||
var targets := _capture_targets(audio)
|
||||
# This synchronous frame owns all strong player/stream/playback locals.
|
||||
# It returns only WeakRefs before the coordinator's first await.
|
||||
if is_instance_valid(audio):
|
||||
targets.append({&"role": &"owner", &"kind": &"audio_owner", &"weak": weakref(audio), &"instance_id": audio.get_instance_id()})
|
||||
audio.call(&"prepare_owner_shutdown")
|
||||
audio.queue_free()
|
||||
return targets
|
||||
|
||||
|
||||
static func close_owners(tree: SceneTree) -> Dictionary:
|
||||
if not tree.root.has_meta(OWNER_META):
|
||||
return {&"coordinator": false}
|
||||
var state: Dictionary = tree.root.get_meta(OWNER_META)
|
||||
if bool(state[&"closing"]):
|
||||
return {&"coordinator": false}
|
||||
state[&"closing"] = true
|
||||
var clock: Node = tree.root.get_node_or_null("GameClock")
|
||||
var clock_was_processing: bool = clock.is_processing() if is_instance_valid(clock) else false
|
||||
var windows: Array[Dictionary] = []
|
||||
_disable_window_input(tree.root, windows)
|
||||
if is_instance_valid(clock):
|
||||
clock.set_process(false)
|
||||
var targets: Array[Dictionary] = []
|
||||
var prepared: Array[int] = []
|
||||
_prepare_pending_owners(state, targets, prepared, windows)
|
||||
var initial := _states(targets)
|
||||
var started := Time.get_ticks_usec()
|
||||
await tree.process_frame
|
||||
while Time.get_ticks_usec() - started < MAXIMUM_WAIT_US:
|
||||
_prepare_pending_owners(state, targets, prepared, windows)
|
||||
if not _any_alive(targets):
|
||||
break
|
||||
await tree.process_frame
|
||||
var waited_us := Time.get_ticks_usec() - started
|
||||
var deadline_reached := waited_us >= MAXIMUM_WAIT_US
|
||||
# Closing lasts through the engine's final frame. Restoring here permits a
|
||||
# real Clock tick after this report and before SceneTree quit takes effect.
|
||||
# Keep only ephemeral process/GUI flags latched; saved values stay exact.
|
||||
var report := {&"coordinator": true, &"owned_engine_pid": OS.get_process_id(), &"owner_instance_ids": prepared,
|
||||
&"waited_actual_us": waited_us, &"maximum_wait_us": MAXIMUM_WAIT_US, &"deadline_reached": deadline_reached,
|
||||
&"initial_targets": initial, &"final_targets": _states(targets), &"all_owned_targets_released": not _any_alive(targets),
|
||||
&"clock_process_before": clock_was_processing, &"clock_process_held_stopped": is_instance_valid(clock) and not clock.is_processing(),
|
||||
&"window_input_held_disabled": _window_inputs_held_disabled(windows), &"original_window_input_flags": _window_input_records(windows),
|
||||
&"restored_before_quit": false,
|
||||
&"shared_bus_deleted": false, &"settings_mutated": false,
|
||||
&"passed": not deadline_reached and not _any_alive(targets) and _window_inputs_held_disabled(windows) and is_instance_valid(clock) and not clock.is_processing()}
|
||||
state[&"last_report"] = report
|
||||
# The static coordinator owns final quit, independently of a Main lifetime.
|
||||
# A normal close keeps Main alive; a QA removal cannot orphan this await.
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var owner: Variant = reference.get_ref()
|
||||
if is_instance_valid(owner):
|
||||
owner.call(&"_report_audio_close_drained", report)
|
||||
if not bool(report[&"passed"]):
|
||||
push_error("UI audio close did not release its owners and hold input/clock stopped through final quit")
|
||||
tree.quit(0 if bool(report[&"passed"]) else 1)
|
||||
return report
|
||||
|
||||
|
||||
static func _disable_window_input(node: Node, windows: Array[Dictionary]) -> void:
|
||||
if node is Window:
|
||||
var window := node as Window
|
||||
var known := false
|
||||
for row: Dictionary in windows:
|
||||
known = known or (row[&"weak"] as WeakRef).get_ref() == window
|
||||
if not known:
|
||||
windows.append({&"weak": weakref(window), &"instance_id": window.get_instance_id(), &"gui_disable_input": window.gui_disable_input})
|
||||
window.gui_disable_input = true
|
||||
if window != node.get_tree().root:
|
||||
window.hide()
|
||||
for child: Node in node.get_children():
|
||||
_disable_window_input(child, windows)
|
||||
|
||||
|
||||
static func _window_input_records(windows: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for row: Dictionary in windows:
|
||||
var window: Variant = (row[&"weak"] as WeakRef).get_ref()
|
||||
result.append({&"instance_id": row[&"instance_id"], &"original_gui_disable_input": bool(row[&"gui_disable_input"]),
|
||||
&"alive_at_final_drain": is_instance_valid(window), &"gui_disable_input_at_final_drain": window.gui_disable_input if is_instance_valid(window) else null})
|
||||
return result
|
||||
|
||||
|
||||
static func _window_inputs_held_disabled(windows: Array[Dictionary]) -> bool:
|
||||
for row: Dictionary in windows:
|
||||
var window: Variant = (row[&"weak"] as WeakRef).get_ref()
|
||||
if is_instance_valid(window) and not window.gui_disable_input:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _prepare_pending_owners(state: Dictionary, targets: Array[Dictionary], prepared: Array[int], windows: Array[Dictionary]) -> void:
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var owner: Variant = reference.get_ref()
|
||||
if not is_instance_valid(owner) or prepared.has(owner.get_instance_id()):
|
||||
continue
|
||||
prepared.append(owner.get_instance_id())
|
||||
_disable_window_input(owner, windows)
|
||||
targets.append_array(owner.call(&"_prepare_owned_audio_close"))
|
||||
|
||||
|
||||
static func _capture_targets(audio: Node) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
if not is_instance_valid(audio):
|
||||
return rows
|
||||
for role: StringName in audio.get(&"_players"):
|
||||
var player: AudioStreamPlayer = audio.get(&"_players")[role]
|
||||
if player.stream != null:
|
||||
rows.append({&"role": role, &"kind": &"stream", &"weak": weakref(player.stream), &"instance_id": player.stream.get_instance_id()})
|
||||
var packets: Variant = player.stream.get(&"packet_sequence")
|
||||
if packets is Resource:
|
||||
rows.append({&"role": role, &"kind": &"packet_sequence", &"weak": weakref(packets), &"instance_id": packets.get_instance_id()})
|
||||
if player.has_stream_playback():
|
||||
var playback := player.get_stream_playback()
|
||||
rows.append({&"role": role, &"kind": &"playback", &"weak": weakref(playback), &"instance_id": playback.get_instance_id()})
|
||||
return rows
|
||||
|
||||
|
||||
static func _states(targets: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for target: Dictionary in targets:
|
||||
var resource: Variant = (target[&"weak"] as WeakRef).get_ref()
|
||||
rows.append({&"role": target[&"role"], &"kind": target[&"kind"], &"instance_id": target[&"instance_id"], &"alive": resource != null, &"class": resource.get_class() if resource != null else &""})
|
||||
return rows
|
||||
|
||||
|
||||
static func _any_alive(targets: Array[Dictionary]) -> bool:
|
||||
for row: Dictionary in _states(targets):
|
||||
if bool(row[&"alive"]):
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbuicrfga4f2t
|
||||
@@ -0,0 +1,106 @@
|
||||
class_name UIAudioSettingsDialog
|
||||
extends AcceptDialog
|
||||
|
||||
var _feedback: Variant
|
||||
var _style_button: Callable
|
||||
var _refreshing := false
|
||||
var master_slider: HSlider
|
||||
var ui_slider: HSlider
|
||||
var mute_button: CheckButton
|
||||
var status_label: Label
|
||||
var _master_label: Label
|
||||
var _ui_label: Label
|
||||
|
||||
|
||||
func configure(feedback: Variant, style_button := Callable()) -> void:
|
||||
_feedback = feedback
|
||||
_style_button = style_button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "UIAudioSettingsDialog"
|
||||
title = "音の設定"
|
||||
ok_button_text = "設定を保存して閉じる"
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 12)
|
||||
add_child(column)
|
||||
_master_label = Label.new()
|
||||
column.add_child(_master_label)
|
||||
master_slider = _slider("MasterVolumeSlider")
|
||||
column.add_child(master_slider)
|
||||
_ui_label = Label.new()
|
||||
column.add_child(_ui_label)
|
||||
ui_slider = _slider("UIVolumeSlider")
|
||||
column.add_child(ui_slider)
|
||||
mute_button = CheckButton.new()
|
||||
mute_button.name = "MuteAudioButton"
|
||||
mute_button.text = "すべての音をミュート"
|
||||
column.add_child(mute_button)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(mute_button)
|
||||
_style_button.call(get_ok_button())
|
||||
status_label = Label.new()
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
column.add_child(status_label)
|
||||
master_slider.value_changed.connect(_on_volume_changed)
|
||||
ui_slider.value_changed.connect(_on_volume_changed)
|
||||
mute_button.toggled.connect(_on_mute_changed)
|
||||
confirmed.connect(_save_settings)
|
||||
refresh()
|
||||
|
||||
|
||||
func _slider(node_name: String) -> HSlider:
|
||||
var slider := HSlider.new()
|
||||
slider.name = node_name
|
||||
slider.min_value = 0.0
|
||||
slider.max_value = 100.0
|
||||
slider.step = 1.0
|
||||
slider.custom_minimum_size.x = 340
|
||||
return slider
|
||||
|
||||
|
||||
func show_settings() -> void:
|
||||
refresh()
|
||||
popup_centered(Vector2i(440, 280))
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _feedback == null or master_slider == null:
|
||||
return
|
||||
_refreshing = true
|
||||
master_slider.set_value_no_signal(float(_feedback.settings[&"master_volume"]) * 100.0)
|
||||
ui_slider.set_value_no_signal(float(_feedback.settings[&"ui_volume"]) * 100.0)
|
||||
mute_button.set_pressed_no_signal(bool(_feedback.settings[&"muted"]))
|
||||
_refreshing = false
|
||||
_refresh_labels()
|
||||
status_label.text = "設定はすぐに音へ反映されます。保存すると次の起動でも使用します。"
|
||||
if not _feedback.settings_writable:
|
||||
status_label.text = "既存の設定を保護するため保存できません。今回の音量変更はこの起動中だけ有効です。"
|
||||
get_ok_button().disabled = not _feedback.settings_writable
|
||||
|
||||
|
||||
func _on_volume_changed(_value: float) -> void:
|
||||
_update_settings()
|
||||
|
||||
|
||||
func _on_mute_changed(_enabled: bool) -> void:
|
||||
_update_settings()
|
||||
|
||||
|
||||
func _update_settings() -> void:
|
||||
if _refreshing or _feedback == null:
|
||||
return
|
||||
_feedback.update_settings({&"master_volume": master_slider.value / 100.0, &"ui_volume": ui_slider.value / 100.0, &"muted": mute_button.button_pressed})
|
||||
_refresh_labels()
|
||||
|
||||
|
||||
func _refresh_labels() -> void:
|
||||
_master_label.text = "マスター音量 %d%%" % int(master_slider.value)
|
||||
_ui_label.text = "UI操作音 %d%%" % int(ui_slider.value)
|
||||
|
||||
|
||||
func _save_settings() -> void:
|
||||
var result: Dictionary = _feedback.save_settings()
|
||||
if not bool(result.get(&"ok", false)):
|
||||
status_label.text = "音の設定を保存できませんでした。現在の音量はこの起動中だけ使用します。"
|
||||
call_deferred(&"popup_centered", Vector2i(440, 280))
|
||||
@@ -0,0 +1 @@
|
||||
uid://yrogab617bce
|
||||
@@ -11,6 +11,7 @@ const LABELS := {
|
||||
&"important_rank_5":"重要クエスト", &"main_chapter_complete":"本編の章目標",
|
||||
}
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _target := -1
|
||||
var _pending_id := 0
|
||||
var _queue_id := 0
|
||||
@@ -62,7 +63,7 @@ func refresh() -> void:
|
||||
get_ok_button().disabled=rank>=ProgressionService.MAX_RANK or not evaluation.get(&"eligible",false) or _pending_id!=0
|
||||
func _promote() -> void:
|
||||
if _pending_id!=0 or _target<2:return
|
||||
var result: CommandResult=_runtime.submit_command(&"set_world_tree_rank",{&"rank":_target,&"request_id":StringName("ui_rank_%d" % _target)})
|
||||
var result: CommandResult=_submit_ui_command(&"set_world_tree_rank",{&"rank":_target,&"request_id":StringName("ui_rank_%d" % _target)})
|
||||
for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]):
|
||||
if result.is_success() and int(command["command_id"])==result.command_id:
|
||||
_pending_id=result.command_id
|
||||
@@ -121,3 +122,17 @@ func apply_layer_skin(layer: int) -> void:
|
||||
LayerSkin.register_text(get_label(), layer)
|
||||
for button: Button in [get_ok_button(), get_cancel_button()]:
|
||||
LayerSkin.register_toggle_text(button, layer)
|
||||
|
||||
|
||||
func configure_audio(submitter: Callable) -> void:
|
||||
_audio_submitter = submitter
|
||||
|
||||
|
||||
func _submit_ui_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if _audio_submitter.is_valid():
|
||||
return _audio_submitter.call(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
|
||||
@@ -76,11 +76,14 @@ var last_no_route_reason: StringName = ROUTE_OK
|
||||
var last_path_cost := 0
|
||||
|
||||
var _passable_cells: Dictionary = {}
|
||||
var _sorted_passable_cell_keys: Array = []
|
||||
var _connectors: Dictionary = {}
|
||||
var _adjacency: Dictionary = {}
|
||||
var _vertical_walk_blockers: Dictionary = {}
|
||||
var _vertical_walk_blocked_cells: Dictionary = {}
|
||||
var _path_cache: Dictionary = {}
|
||||
# Derived state limited to one synchronous candidate projection, never serialized.
|
||||
var _query_batch: Dictionary = {}
|
||||
var _cell_traffic: Dictionary = {}
|
||||
var _actor_cells: Dictionary = {}
|
||||
var _actor_progress: Dictionary = {}
|
||||
@@ -272,6 +275,7 @@ func configure_connector(
|
||||
and not _active_request_id_exists(StringName(request_id)):
|
||||
last_error = "Connector configuration reuses a retired elevator request id."
|
||||
return false
|
||||
var previous_configuration: Dictionary = _connectors.get(connector_id, {})
|
||||
_connectors[connector_id] = configuration
|
||||
var next_active: Dictionary = index_result[&"active_units"]
|
||||
for unit_id: Variant in _active_elevator_units:
|
||||
@@ -282,7 +286,7 @@ func configure_connector(
|
||||
_refresh_transit_actor_ids()
|
||||
for request_id: Variant in index_result[&"known_request_ids"]:
|
||||
_known_elevator_request_ids[request_id] = true
|
||||
_rebuild_derived_graph()
|
||||
_rebuild_connector_endpoint_edges(previous_configuration, configuration)
|
||||
_mark_graph_changed()
|
||||
last_error = ""
|
||||
return true
|
||||
@@ -1021,7 +1025,14 @@ func _find_path_internal(
|
||||
_path_cache.erase(cache_key)
|
||||
|
||||
cache_misses += 1
|
||||
var path := _run_a_star(start, goal, allowed_layers, allowed_access_ids)
|
||||
if start.z != goal.z and not _guest_batch_has_permitted_layer_crossing(start, goal, allowed_layers, allowed_access_ids):
|
||||
return _route_failure(ROUTE_GOAL_UNREACHABLE)
|
||||
var shared: Variant = _query_batch_path(start, goal, allowed_layers, allowed_access_ids)
|
||||
var path: Array[Vector3i] = []
|
||||
if shared == null:
|
||||
path = _run_a_star(start, goal, allowed_layers, allowed_access_ids)
|
||||
else:
|
||||
path.assign(shared)
|
||||
if path.is_empty():
|
||||
return _route_failure(_diagnose_no_route_reason(
|
||||
start,
|
||||
@@ -1031,11 +1042,282 @@ func _find_path_internal(
|
||||
))
|
||||
_path_cache[cache_key] = _copy_path(path)
|
||||
last_no_route_reason = ROUTE_OK
|
||||
last_path_cost = _calculate_path_cost_for_access(path, allowed_access_ids)
|
||||
var batch_cost := -1
|
||||
if allowed_access_ids.is_empty() and allowed_layers == [FRONT,BACK]:
|
||||
batch_cost = _guest_batch_path_cost(start,goal,path)
|
||||
last_path_cost = batch_cost if batch_cost >= 0 else _calculate_path_cost_for_access(path, allowed_access_ids)
|
||||
last_error = ""
|
||||
return path
|
||||
|
||||
|
||||
# Only the default guest access profile is shared. A mutation invalidates the
|
||||
# whole batch through the same public cache invalidation paths as ordinary routes.
|
||||
func begin_guest_path_query_batch(start: Vector3i, goals: Array) -> void:
|
||||
_query_batch.clear()
|
||||
# Without connectors the original Manhattan solver stops on goal pop.
|
||||
# Equal-cost canonical parents can then differ: retain that solver exactly.
|
||||
if _connectors.is_empty(): return
|
||||
var layers: Array[int] = [FRONT, BACK]
|
||||
var access: Array[StringName] = []
|
||||
var targets: Dictionary = {}
|
||||
var crossings: Dictionary = {}
|
||||
for value: Variant in goals:
|
||||
if not value is Vector3i: continue
|
||||
var goal: Vector3i = value
|
||||
if not _passable_cells.has(goal) or _cell_is_temporarily_blocked(goal): continue
|
||||
if start.z != goal.z:
|
||||
if not crossings.has(goal.z): crossings[goal.z] = _has_permitted_layer_crossing(start.z, goal.z, access)
|
||||
if not bool(crossings[goal.z]): continue
|
||||
targets[goal] = true
|
||||
if not targets.is_empty() and _passable_cells.has(start) and not _cell_is_temporarily_blocked(start):
|
||||
_query_batch = {&"start":start, &"targets":targets, &"ready":false, &"tree":{}, &"crossings":crossings}
|
||||
|
||||
|
||||
# Directed layer permission depends only on connectors/access in this synchronous
|
||||
# default guest batch. All existing invalidation and restore paths clear it.
|
||||
func _guest_batch_has_permitted_layer_crossing(start: Vector3i, goal: Vector3i, layers: Array[int], access: Array[StringName]) -> bool:
|
||||
if not _query_batch.is_empty() and start == _query_batch[&"start"] and layers == [FRONT,BACK] and access.is_empty():
|
||||
var crossings: Dictionary = _query_batch[&"crossings"]
|
||||
if crossings.has(goal.z): return bool(crossings[goal.z])
|
||||
return _has_permitted_layer_crossing(start.z,goal.z,access)
|
||||
|
||||
|
||||
func end_guest_path_query_batch() -> void:
|
||||
_query_batch.clear()
|
||||
|
||||
|
||||
func _query_batch_path(start: Vector3i, goal: Vector3i, layers: Array[int], access: Array[StringName]) -> Variant:
|
||||
if _query_batch.is_empty() or start != _query_batch[&"start"] \
|
||||
or layers != [FRONT, BACK] or not access.is_empty() or not _query_batch[&"targets"].has(goal):
|
||||
return null
|
||||
if start == goal: return [start]
|
||||
if not bool(_query_batch[&"ready"]):
|
||||
_query_batch[&"tree"] = _build_guest_query_tree(start, _query_batch[&"targets"], layers, access)
|
||||
_query_batch[&"arithmetic_wrapped"] = _guest_tree_arithmetic_wrapped
|
||||
_query_batch[&"ready"] = true
|
||||
var tree: Dictionary = _query_batch[&"tree"]
|
||||
if not bool(tree.get(&"usable", false)): return null
|
||||
var settled: Dictionary = tree[&"settled"]
|
||||
if not settled.has(goal): return []
|
||||
return _reconstruct_path(tree[&"parents"], goal)
|
||||
|
||||
|
||||
# Positive edge costs make every shortest predecessor settle before its child.
|
||||
# Resolving equal costs by the original coordinate parent rule gives the same
|
||||
# canonical paths as the admissible goal search. Stop after requested goals, not
|
||||
# after visiting the entire graph. Any nonpositive edge retains the old solver.
|
||||
var _guest_tree_workspace: Dictionary = {}
|
||||
# Derived search state only. Wrapped accumulated costs cannot price a cell path.
|
||||
var _guest_tree_arithmetic_wrapped: bool = false
|
||||
|
||||
func _build_guest_query_tree(start: Vector3i, goals: Dictionary, layers: Array[int], access: Array[StringName]) -> Dictionary:
|
||||
_guest_tree_arithmetic_wrapped = false
|
||||
if get_script().resource_path != "res://scripts/world/path_service.gd" or layers != [FRONT,BACK] or not access.is_empty():
|
||||
return _build_guest_query_tree_uncached(start,goals,layers,access)
|
||||
return _build_guest_query_tree_shared(start,goals,layers,access)
|
||||
|
||||
func _build_guest_query_tree_uncached(start: Vector3i, goals: Dictionary, layers: Array[int], access: Array[StringName]) -> Dictionary:
|
||||
# The sorted graph cells preserve the original x/y/layer heap and parent order.
|
||||
# All numeric workspace is local to this search, including the coordinate map.
|
||||
var cells: Array = _sorted_passable_cell_keys
|
||||
var indices: Dictionary = {}
|
||||
for index: int in range(cells.size()): indices[cells[index]] = index
|
||||
if not indices.has(start): return {&"usable":false}
|
||||
var costs := PackedInt64Array()
|
||||
var parents := PackedInt64Array()
|
||||
var finalized := PackedByteArray()
|
||||
if costs.resize(cells.size()) != OK or parents.resize(cells.size()) != OK or finalized.resize(cells.size()) != OK:
|
||||
return {&"usable":false}
|
||||
costs.fill(_INFINITE_COST)
|
||||
parents.fill(-1)
|
||||
var start_index: int = indices[start]
|
||||
costs[start_index] = 0
|
||||
var discovered: Array[int] = [start_index]
|
||||
var parent_discovered: Array[int] = []
|
||||
var heap_nodes: Array[int] = []
|
||||
var heap_costs: Array[int] = []
|
||||
_indexed_tree_heap_push(heap_nodes, heap_costs, start_index, 0)
|
||||
var pending: Dictionary = goals.duplicate()
|
||||
var settled: Dictionary = {}
|
||||
while not heap_nodes.is_empty():
|
||||
var entry_cost: int = heap_costs[0]
|
||||
var current_index := _indexed_tree_heap_pop(heap_nodes, heap_costs)
|
||||
if finalized[current_index] != 0 or entry_cost != costs[current_index]: continue
|
||||
var current: Vector3i = cells[current_index]
|
||||
finalized[current_index] = 1
|
||||
settled[current] = true
|
||||
pending.erase(current)
|
||||
if pending.is_empty(): break
|
||||
var edges: Array = _adjacency.get(current, [])
|
||||
for value: Variant in edges:
|
||||
var edge: Dictionary = value
|
||||
var neighbor: Vector3i = edge[&"to"]
|
||||
if neighbor.z not in layers or _cell_is_temporarily_blocked(neighbor) \
|
||||
or not _edge_meets_vertical_policy(current,edge) or not _edge_is_accessible(edge,access): continue
|
||||
var edge_cost := _edge_total_cost(edge)
|
||||
if edge_cost <= 0: return {&"usable":false}
|
||||
if not indices.has(neighbor): return {&"usable":false}
|
||||
var neighbor_index: int = indices[neighbor]
|
||||
var cost := costs[current_index] + edge_cost
|
||||
var known := costs[neighbor_index]
|
||||
var better_parent := cost == known and parents[neighbor_index] >= 0 and current_index < parents[neighbor_index]
|
||||
if cost > known or (cost == known and not better_parent): continue
|
||||
if parents[neighbor_index] < 0:
|
||||
parent_discovered.append(neighbor_index)
|
||||
if neighbor_index != start_index: discovered.append(neighbor_index)
|
||||
parents[neighbor_index] = current_index
|
||||
costs[neighbor_index] = cost
|
||||
finalized[neighbor_index] = 0
|
||||
settled.erase(neighbor)
|
||||
_indexed_tree_heap_push(heap_nodes, heap_costs, neighbor_index, cost)
|
||||
# Keep the existing result dictionaries and their first-discovery order.
|
||||
var result_parents: Dictionary = {}
|
||||
var result_costs: Dictionary = {}
|
||||
for index: int in discovered:
|
||||
var cell: Vector3i = cells[index]
|
||||
result_costs[cell] = costs[index]
|
||||
for index: int in parent_discovered:
|
||||
result_parents[cells[index]] = cells[parents[index]]
|
||||
return {&"usable":true,&"settled":settled,&"parents":result_parents,&"costs":result_costs}
|
||||
|
||||
|
||||
func _build_guest_query_tree_shared(start: Vector3i, goals: Dictionary, layers: Array[int], access: Array[StringName]) -> Dictionary:
|
||||
# The sorted graph cells preserve the original x/y/layer heap and parent order.
|
||||
# All numeric workspace is local to this search, including the coordinate map.
|
||||
var cells: Array = _sorted_passable_cell_keys
|
||||
if _guest_tree_workspace.is_empty():
|
||||
var fresh_indices: Dictionary = {}
|
||||
for index: int in range(cells.size()): fresh_indices[cells[index]] = index
|
||||
_guest_tree_workspace = {&"indices":fresh_indices, &"edges":{}}
|
||||
var indices: Dictionary = _guest_tree_workspace[&"indices"]
|
||||
if not indices.has(start): return {&"usable":false}
|
||||
var costs := PackedInt64Array()
|
||||
var parents := PackedInt64Array()
|
||||
var finalized := PackedByteArray()
|
||||
if costs.resize(cells.size()) != OK or parents.resize(cells.size()) != OK or finalized.resize(cells.size()) != OK:
|
||||
return {&"usable":false}
|
||||
costs.fill(_INFINITE_COST)
|
||||
parents.fill(-1)
|
||||
var start_index: int = indices[start]
|
||||
costs[start_index] = 0
|
||||
var discovered: Array[int] = [start_index]
|
||||
var parent_discovered: Array[int] = []
|
||||
var heap_nodes: Array[int] = []
|
||||
var heap_costs: Array[int] = []
|
||||
_indexed_tree_heap_push(heap_nodes, heap_costs, start_index, 0)
|
||||
var pending: Dictionary = goals.duplicate()
|
||||
var settled: Dictionary = {}
|
||||
while not heap_nodes.is_empty():
|
||||
var entry_cost: int = heap_costs[0]
|
||||
var current_index := _indexed_tree_heap_pop(heap_nodes, heap_costs)
|
||||
if finalized[current_index] != 0 or entry_cost != costs[current_index]: continue
|
||||
var current: Vector3i = cells[current_index]
|
||||
finalized[current_index] = 1
|
||||
settled[current] = true
|
||||
pending.erase(current)
|
||||
if pending.is_empty(): break
|
||||
var edges: Array = _guest_tree_shared_edges(current, layers, access)
|
||||
for value: Variant in edges:
|
||||
var row: Array = value
|
||||
var neighbor: Vector3i = row[0]
|
||||
var edge_cost: int = row[1]
|
||||
if edge_cost <= 0: return {&"usable":false}
|
||||
if not indices.has(neighbor): return {&"usable":false}
|
||||
var neighbor_index: int = indices[neighbor]
|
||||
var cost := costs[current_index] + edge_cost
|
||||
if cost < costs[current_index]: _guest_tree_arithmetic_wrapped = true
|
||||
var known := costs[neighbor_index]
|
||||
var better_parent := cost == known and parents[neighbor_index] >= 0 and current_index < parents[neighbor_index]
|
||||
if cost > known or (cost == known and not better_parent): continue
|
||||
if parents[neighbor_index] < 0:
|
||||
parent_discovered.append(neighbor_index)
|
||||
if neighbor_index != start_index: discovered.append(neighbor_index)
|
||||
parents[neighbor_index] = current_index
|
||||
costs[neighbor_index] = cost
|
||||
finalized[neighbor_index] = 0
|
||||
settled.erase(neighbor)
|
||||
_indexed_tree_heap_push(heap_nodes, heap_costs, neighbor_index, cost)
|
||||
# Keep the existing result dictionaries and their first-discovery order.
|
||||
var result_parents: Dictionary = {}
|
||||
var result_costs: Dictionary = {}
|
||||
for index: int in discovered:
|
||||
var cell: Vector3i = cells[index]
|
||||
result_costs[cell] = costs[index]
|
||||
for index: int in parent_discovered:
|
||||
result_parents[cells[index]] = cells[parents[index]]
|
||||
return {&"usable":true,&"settled":settled,&"parents":result_parents,&"costs":result_costs}
|
||||
|
||||
|
||||
func _guest_tree_shared_edges(current: Vector3i, layers: Array[int], access: Array[StringName]) -> Array:
|
||||
var shared: Dictionary = _guest_tree_workspace[&"edges"]
|
||||
if shared.has(current): return shared[current]
|
||||
var result: Array = []
|
||||
for value: Variant in _adjacency.get(current,[]):
|
||||
var edge: Dictionary = value
|
||||
var neighbor: Vector3i = edge[&"to"]
|
||||
if neighbor.z not in layers or _cell_is_temporarily_blocked(neighbor) \
|
||||
or not _edge_meets_vertical_policy(current,edge) or not _edge_is_accessible(edge,access): continue
|
||||
var cost := _edge_total_cost(edge)
|
||||
result.append([neighbor,cost])
|
||||
if cost <= 0 or not (_guest_tree_workspace[&"indices"] as Dictionary).has(neighbor): break
|
||||
shared[current]=result
|
||||
return result
|
||||
|
||||
func _indexed_tree_heap_push(nodes: Array[int], costs: Array[int], node: int, cost: int) -> void:
|
||||
nodes.append(node)
|
||||
costs.append(cost)
|
||||
var index := nodes.size() - 1
|
||||
while index > 0:
|
||||
var parent := (index - 1) >> 1
|
||||
if cost > costs[parent] or (cost == costs[parent] and node >= nodes[parent]): break
|
||||
nodes[index] = nodes[parent]
|
||||
costs[index] = costs[parent]
|
||||
index = parent
|
||||
nodes[index] = node
|
||||
costs[index] = cost
|
||||
|
||||
|
||||
func _indexed_tree_heap_pop(nodes: Array[int], costs: Array[int]) -> int:
|
||||
var first := nodes[0]
|
||||
var last_node: int = nodes.pop_back()
|
||||
var last_cost: int = costs.pop_back()
|
||||
if nodes.is_empty(): return first
|
||||
var index := 0
|
||||
while index * 2 + 1 < nodes.size():
|
||||
var child := index * 2 + 1
|
||||
if child + 1 < nodes.size() and (costs[child + 1] < costs[child] or (costs[child + 1] == costs[child] and nodes[child + 1] < nodes[child])):
|
||||
child += 1
|
||||
if costs[child] > last_cost or (costs[child] == last_cost and nodes[child] >= last_node): break
|
||||
nodes[index] = nodes[child]
|
||||
costs[index] = costs[child]
|
||||
index = child
|
||||
nodes[index] = last_node
|
||||
costs[index] = last_cost
|
||||
return first
|
||||
|
||||
|
||||
# A matching current batch route without arithmetic wrap has an exact cost. Compare
|
||||
# the whole path, not only its endpoints: a valid alternate may cost differently.
|
||||
# Mutation/restore invalidation clears the batch even for equal revision numbers.
|
||||
func _guest_batch_path_cost(start: Vector3i, goal: Vector3i, path: Array) -> int:
|
||||
if _query_batch.is_empty() or start != _query_batch[&"start"] or not _query_batch[&"targets"].has(goal): return -1
|
||||
if start == goal and path == [start]: return 0
|
||||
if not bool(_query_batch[&"ready"]): return -1
|
||||
if bool(_query_batch.get(&"arithmetic_wrapped", false)): return -1
|
||||
var tree: Dictionary = _query_batch[&"tree"]
|
||||
if not bool(tree.get(&"usable",false)) or not tree[&"settled"].has(goal): return -1
|
||||
if path != _reconstruct_path(tree[&"parents"],goal): return -1
|
||||
return int(tree[&"costs"][goal])
|
||||
|
||||
|
||||
# Same public last_error behavior as default-access calculate_path_cost.
|
||||
func calculate_guest_batch_path_cost(start: Vector3i, goal: Vector3i, path: Array) -> int:
|
||||
var cost := _guest_batch_path_cost(start,goal,path)
|
||||
if cost < 0: return calculate_path_cost(path)
|
||||
last_error = ""
|
||||
return cost
|
||||
|
||||
|
||||
func find_path_report(
|
||||
start: Vector3i,
|
||||
goal: Vector3i,
|
||||
@@ -1087,6 +1369,8 @@ func invalidate_cache_for_cells(cells: Variant) -> int:
|
||||
if not normalization[&"ok"]:
|
||||
last_error = normalization[&"error"]
|
||||
return 0
|
||||
_query_batch.clear()
|
||||
_guest_tree_workspace.clear()
|
||||
var affected: Dictionary = normalization[&"cells"]
|
||||
var keys_to_remove: Array = []
|
||||
for cache_key: Variant in _path_cache:
|
||||
@@ -1104,6 +1388,8 @@ func invalidate_local_cache(cells: Variant) -> int:
|
||||
|
||||
|
||||
func invalidate_all_cache() -> int:
|
||||
_query_batch.clear()
|
||||
_guest_tree_workspace.clear()
|
||||
var removed := _path_cache.size()
|
||||
_path_cache.clear()
|
||||
return removed
|
||||
@@ -1158,8 +1444,20 @@ func get_edge_report(from_cell: Vector3i, to_cell: Vector3i, connector_access: V
|
||||
return report
|
||||
|
||||
|
||||
# The caller only needs the selected connector id. Avoid constructing the
|
||||
# detailed report and its extra cost calculation. Without a connector edge to
|
||||
# this destination every possible selected edge has the empty connector id.
|
||||
func get_step_connector(from_cell: Vector3i, to_cell: Vector3i, connector_access: Variant = null) -> StringName:
|
||||
return StringName(get_edge_report(from_cell, to_cell, connector_access).get(&"connector_id", &""))
|
||||
var normalized := _normalize_access_ids(connector_access)
|
||||
if not bool(normalized[&"ok"]):
|
||||
last_error = String(normalized[&"error"])
|
||||
return &""
|
||||
if _cell_is_temporarily_blocked(from_cell) or _cell_is_temporarily_blocked(to_cell):
|
||||
return &""
|
||||
for edge: Dictionary in _adjacency.get(from_cell, []):
|
||||
if edge[&"to"] == to_cell and not StringName(edge[&"connector_id"]).is_empty():
|
||||
return StringName(_find_edge(from_cell,to_cell,normalized[&"values"]).get(&"connector_id", &""))
|
||||
return &""
|
||||
|
||||
|
||||
func get_neighbors(cell: Vector3i) -> Array:
|
||||
@@ -1322,8 +1620,7 @@ func create_snapshot() -> Dictionary:
|
||||
connector[&"request_queue"] = queue
|
||||
saved_connectors.append(connector)
|
||||
|
||||
var cells: Array = _passable_cells.keys()
|
||||
cells.sort_custom(Callable(self, "_coordinate_less"))
|
||||
var cells: Array = _sorted_passable_cell_keys
|
||||
var saved_cell_traffic: Array = []
|
||||
for cell_value: Variant in cells:
|
||||
var cell: Vector3i = cell_value
|
||||
@@ -1536,10 +1833,56 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
## A single connector changes only the outgoing edges of its old/new endpoints.
|
||||
## Keep the same walk-edge rules, sorted connector iteration and edge ordering
|
||||
## as the full rebuild. Topology changes still use the full graph rebuild.
|
||||
func _rebuild_connector_endpoint_edges(previous: Dictionary, current: Dictionary) -> void:
|
||||
var affected: Dictionary = {}
|
||||
for configuration: Dictionary in [previous, current]:
|
||||
if configuration.is_empty():
|
||||
continue
|
||||
for key: StringName in [&"from", &"to"]:
|
||||
var cell: Vector3i = configuration[key]
|
||||
if _passable_cells.has(cell):
|
||||
affected[cell] = true
|
||||
var cells: Array = affected.keys()
|
||||
cells.sort_custom(Callable(self, "_coordinate_less"))
|
||||
for cell_value: Variant in cells:
|
||||
var cell: Vector3i = cell_value
|
||||
_adjacency[cell] = []
|
||||
for direction in _CARDINAL_DIRECTIONS:
|
||||
var neighbor := cell + direction
|
||||
if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(cell, neighbor):
|
||||
_append_edge(cell, neighbor, ORTHOGONAL_MOVE_COST, &"", &"", MOVEMENT_WALK)
|
||||
var connector_ids: Array = _connectors.keys()
|
||||
connector_ids.sort_custom(Callable(self, "_string_name_less"))
|
||||
for connector_id_value: Variant in connector_ids:
|
||||
var connector_id: StringName = connector_id_value
|
||||
var connector: Dictionary = _connectors[connector_id]
|
||||
if not _connector_is_operational(connector):
|
||||
continue
|
||||
var from_cell: Vector3i = connector[&"from"]
|
||||
var to_cell: Vector3i = connector[&"to"]
|
||||
if not _passable_cells.has(from_cell) or not _passable_cells.has(to_cell):
|
||||
continue
|
||||
var cost: int = connector[&"cost"]
|
||||
var access_id: StringName = connector[&"access_id"]
|
||||
var movement_type: StringName = connector[&"movement_type"]
|
||||
if affected.has(from_cell):
|
||||
_append_edge(from_cell, to_cell, cost, connector_id, access_id, movement_type)
|
||||
if bool(connector[&"bidirectional"]) and affected.has(to_cell):
|
||||
_append_edge(to_cell, from_cell, cost, connector_id, access_id, movement_type)
|
||||
for cell_value: Variant in cells:
|
||||
var edges: Array = _adjacency[cell_value]
|
||||
edges.sort_custom(Callable(self, "_edge_less"))
|
||||
_adjacency[cell_value] = edges
|
||||
|
||||
|
||||
func _rebuild_derived_graph() -> void:
|
||||
_adjacency.clear()
|
||||
var cells: Array = _passable_cells.keys()
|
||||
cells.sort_custom(Callable(self, "_coordinate_less"))
|
||||
_sorted_passable_cell_keys = cells
|
||||
for cell_value: Variant in cells:
|
||||
var cell: Vector3i = cell_value
|
||||
_adjacency[cell] = []
|
||||
@@ -1611,19 +1954,26 @@ func _run_a_star(
|
||||
if start == goal:
|
||||
return [start]
|
||||
|
||||
var connector_scale := _positive_connector_heuristic_scale()
|
||||
var settled_goal_cost := _INFINITE_COST
|
||||
var frontier: Array[Array] = []
|
||||
_heap_push(frontier, [start, 0, _heuristic(start, goal)])
|
||||
_heap_push(frontier, [start, 0, _query_distance_lower_bound(start, goal, connector_scale)])
|
||||
var closed_set: Dictionary = {}
|
||||
var came_from: Dictionary = {}
|
||||
var g_score: Dictionary = {start: 0}
|
||||
|
||||
while not frontier.is_empty():
|
||||
var entry := _heap_pop(frontier)
|
||||
if connector_scale > 0 and int(entry[2]) > settled_goal_cost:
|
||||
break
|
||||
var current: Vector3i = entry[0]
|
||||
if closed_set.has(current) or int(entry[1]) != int(g_score[current]):
|
||||
continue
|
||||
if current == goal:
|
||||
return _reconstruct_path(came_from, current)
|
||||
if connector_scale <= 0:
|
||||
return _reconstruct_path(came_from, current)
|
||||
settled_goal_cost = int(entry[1])
|
||||
continue
|
||||
|
||||
closed_set[current] = true
|
||||
var edges: Array = _adjacency.get(current, [])
|
||||
@@ -1647,11 +1997,37 @@ func _run_a_star(
|
||||
g_score[neighbor] = tentative_cost
|
||||
if closed_set.has(neighbor):
|
||||
closed_set.erase(neighbor)
|
||||
_heap_push(frontier, [neighbor, tentative_cost, tentative_cost + _heuristic(neighbor, goal)])
|
||||
_heap_push(frontier, [neighbor, tentative_cost, tentative_cost + _query_distance_lower_bound(neighbor, goal, connector_scale)])
|
||||
|
||||
if settled_goal_cost < _INFINITE_COST:
|
||||
return _reconstruct_path(came_from, goal)
|
||||
return []
|
||||
|
||||
|
||||
## Every permitted edge costs at least scale * its Manhattan displacement.
|
||||
## A zero-cost connector keeps the existing conservative search unchanged.
|
||||
func _positive_connector_heuristic_scale() -> int:
|
||||
if _connectors.is_empty():
|
||||
return 0
|
||||
var scale := ORTHOGONAL_MOVE_COST
|
||||
for connector_value: Variant in _connectors.values():
|
||||
var connector: Dictionary = connector_value
|
||||
var from_cell: Vector3i = connector[&"from"]
|
||||
var to_cell: Vector3i = connector[&"to"]
|
||||
var displacement := absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y) + absi(from_cell.z - to_cell.z)
|
||||
if displacement > 0:
|
||||
scale = mini(scale, int(connector[&"cost"]) / displacement)
|
||||
if scale <= 0:
|
||||
return 0
|
||||
return scale
|
||||
|
||||
|
||||
func _query_distance_lower_bound(from_cell: Vector3i, to_cell: Vector3i, connector_scale: int) -> int:
|
||||
if connector_scale <= 0:
|
||||
return _heuristic(from_cell, to_cell)
|
||||
return (absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y) + absi(from_cell.z - to_cell.z)) * connector_scale
|
||||
|
||||
|
||||
# Query-local heap. Stale improved-cost entries are skipped on removal.
|
||||
# Keep the original f-score / x / y / layer order, including reopened nodes.
|
||||
func _heap_less(left: Array, right: Array) -> bool:
|
||||
@@ -1796,6 +2172,23 @@ func _cell_is_temporarily_blocked(cell: Vector3i) -> bool:
|
||||
return not traffic.is_empty() and bool(traffic[&"temporary_block"])
|
||||
|
||||
|
||||
## Cardinal walking never changes layers. Even an unavailable or blocked
|
||||
## connector must be retained here so the ordinary solver preserves its reason.
|
||||
## Only the absence of any permitted directed crossing proves unreachable.
|
||||
func _has_permitted_layer_crossing(from_layer: int, to_layer: int, allowed_access_ids: Array[StringName]) -> bool:
|
||||
for connector: Dictionary in _connectors.values():
|
||||
var access_id: StringName = connector[&"access_id"]
|
||||
if not access_id.is_empty() and access_id not in allowed_access_ids:
|
||||
continue
|
||||
var from_cell: Vector3i = connector[&"from"]
|
||||
var to_cell: Vector3i = connector[&"to"]
|
||||
if from_cell.z == from_layer and to_cell.z == to_layer:
|
||||
return true
|
||||
if bool(connector[&"bidirectional"]) and to_cell.z == from_layer and from_cell.z == to_layer:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _diagnose_no_route_reason(
|
||||
start: Vector3i,
|
||||
goal: Vector3i,
|
||||
|
||||
@@ -191,8 +191,8 @@ func get_quarantine_reason() -> StringName:
|
||||
return _quarantine_reason
|
||||
|
||||
|
||||
func get_base_walkable_cells() -> Array[Vector3i]:
|
||||
return _sorted_cells(_base_walkable_cells.keys())
|
||||
func get_base_walkable_cells(sort_result := true) -> Array[Vector3i]:
|
||||
return _sorted_cells(_base_walkable_cells.keys(), sort_result)
|
||||
|
||||
|
||||
func get_projected_route_cells() -> Array[Vector3i]:
|
||||
@@ -563,7 +563,7 @@ func _projection_status_internal() -> Dictionary:
|
||||
}
|
||||
var current_source_revision := _source.get_revision_for_category(_source.CATEGORY_ROUTE)
|
||||
var current_route_cells := _source.get_route_cells()
|
||||
var path_capture := _capture_path_state(_path)
|
||||
var path_capture := _capture_path_state(_path, false)
|
||||
var source_changed: bool = (
|
||||
current_source_revision != source_revision
|
||||
or current_route_cells != _projected_route_cells
|
||||
@@ -573,7 +573,7 @@ func _projection_status_internal() -> Dictionary:
|
||||
or path_capture[&"cells"] != _projected_walkable_cells
|
||||
)
|
||||
var projection_crosslink_ok: bool = (
|
||||
_union_cells(get_base_walkable_cells(), _projected_route_cells)
|
||||
_union_cells(get_base_walkable_cells(false), _projected_route_cells)
|
||||
== _projected_walkable_cells
|
||||
)
|
||||
return {
|
||||
@@ -690,7 +690,7 @@ func _quarantine(reason: StringName) -> void:
|
||||
_transaction_active = false
|
||||
|
||||
|
||||
static func _capture_path_state(path_service: PathService) -> Dictionary:
|
||||
static func _capture_path_state(path_service: PathService, capture_snapshot := true) -> Dictionary:
|
||||
var snapshot := path_service.create_snapshot()
|
||||
var parsed_cells := _path_cells_from_snapshot(snapshot)
|
||||
if not bool(parsed_cells[&"ok"]):
|
||||
@@ -701,11 +701,11 @@ static func _capture_path_state(path_service: PathService) -> Dictionary:
|
||||
&"ok": false,
|
||||
&"message": "Path snapshot topology has not been materialized into the live graph.",
|
||||
}
|
||||
return {
|
||||
&"ok": true,
|
||||
&"snapshot": snapshot.duplicate(true),
|
||||
&"cells": (parsed_cells[&"cells"] as Array).duplicate(),
|
||||
}
|
||||
var result := {&"ok": true}
|
||||
if capture_snapshot:
|
||||
result[&"snapshot"] = snapshot.duplicate(true)
|
||||
result[&"cells"] = (parsed_cells[&"cells"] as Array).duplicate()
|
||||
return result
|
||||
|
||||
|
||||
static func _path_cells_from_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
@@ -1072,14 +1072,15 @@ static func _union_cells(first: Array, second: Array) -> Array[Vector3i]:
|
||||
return _sorted_cells(union.keys())
|
||||
|
||||
|
||||
static func _sorted_cells(source: Variant) -> Array[Vector3i]:
|
||||
static func _sorted_cells(source: Variant, sort_result := true) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for raw_cell: Variant in source:
|
||||
if raw_cell is Vector3i:
|
||||
result.append(raw_cell)
|
||||
result.sort_custom(_cell_less)
|
||||
if sort_result:
|
||||
result.sort_custom(_cell_less)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -229,8 +229,8 @@ func is_walkable(cell: Vector3i) -> bool:
|
||||
return is_in_bounds(cell) and bool(_walkable.get(cell, false))
|
||||
|
||||
|
||||
func get_walkable_cells() -> Array[Vector3i]:
|
||||
return _sorted_dictionary_cells(_walkable)
|
||||
func get_walkable_cells(sort_result := true) -> Array[Vector3i]:
|
||||
return _sorted_dictionary_cells(_walkable, sort_result)
|
||||
|
||||
|
||||
func set_excavated(cell: Vector3i, value: bool) -> bool:
|
||||
@@ -728,12 +728,13 @@ func _sorted_entity_ids() -> Array[StringName]:
|
||||
return result
|
||||
|
||||
|
||||
func _sorted_dictionary_cells(source: Dictionary) -> Array[Vector3i]:
|
||||
func _sorted_dictionary_cells(source: Dictionary, sort_result := true) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
for cell: Variant in source:
|
||||
if cell is Vector3i:
|
||||
result.append(cell)
|
||||
result.sort_custom(_cell_less)
|
||||
if sort_result:
|
||||
result.sort_custom(_cell_less)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user