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:
Takeshi
2026-10-11 15:58:00 +09:00
parent 39f6068461
commit b84bade045
235 changed files with 495831 additions and 312 deletions
+12 -5
View File
@@ -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()
+86 -7
View File
@@ -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
View File
@@ -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,
+2 -2
View File
@@ -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)
)
+36 -1
View File
@@ -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
View File
@@ -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
+8 -2
View File
@@ -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"):
+85 -5
View File
@@ -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:
+3 -1
View File
@@ -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
+43 -3
View File
@@ -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
+49 -2
View File
@@ -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):
+297
View File
@@ -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
+160 -5
View File
@@ -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)
+41 -11
View File
@@ -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)
+315
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://ddbo74xrnvpxd
+10 -3
View File
@@ -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
+1 -1
View File
@@ -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)))
+54
View File
@@ -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"))
+1
View File
@@ -0,0 +1 @@
uid://dc4xv2kx0ah3u
+144
View File
@@ -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
+174 -1
View File
@@ -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)
+108
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://cve5judsgtgcy
+16
View File
@@ -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)
+13 -2
View File
@@ -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":
+6
View File
@@ -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"])
+32 -1
View File
@@ -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)
+142
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bophxjq1fck3q
+27 -4
View File
@@ -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)
+329
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
uid://u38heyutw0xp
+171
View File
@@ -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
+106
View File
@@ -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
+16 -1
View File
@@ -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)
+402 -9
View File
@@ -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,
+13 -12
View File
@@ -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
+5 -4
View File
@@ -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