Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
extends SceneTree
|
||||
|
||||
const CommandType = preload("res://scripts/core/command.gd")
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
const CommandQueueType = preload("res://scripts/core/command_queue.gd")
|
||||
const TransactionType = preload("res://scripts/core/state_transaction.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S0: Command Queue and State Transaction")
|
||||
_test_deterministic_queue_order()
|
||||
_test_idempotency_and_result_retention()
|
||||
_test_snapshot_preserves_accepted_idempotency_history()
|
||||
_test_legacy_snapshot_migrates_accepted_metadata()
|
||||
_test_transaction_commit()
|
||||
_test_validation_is_atomic()
|
||||
_test_apply_failure_rolls_back()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_deterministic_queue_order() -> void:
|
||||
var queue = CommandQueueType.new()
|
||||
_expect(queue.enqueue(_command(3, 11, 100, &"three")).is_success(), "command 3 accepted")
|
||||
_expect(queue.enqueue(_command(2, 10, 10, &"two")).is_success(), "command 2 accepted")
|
||||
_expect(queue.enqueue(_command(1, 10, 10, &"one")).is_success(), "command 1 accepted")
|
||||
_expect(queue.enqueue(_command(4, 10, 90, &"four")).is_success(), "command 4 accepted")
|
||||
var ready: Array = queue.drain_ready(10)
|
||||
var ids: Array[int] = []
|
||||
for command in ready:
|
||||
ids.append(command.command_id)
|
||||
_expect(ids == [4, 1, 2], "ready order is tick, descending priority, command id")
|
||||
_expect(queue.next_execute_tick() == 11, "future command remains queued")
|
||||
|
||||
|
||||
func _test_idempotency_and_result_retention() -> void:
|
||||
var queue = CommandQueueType.new()
|
||||
var original = _command(10, 1, 0, &"same-operation")
|
||||
_expect(queue.enqueue(original).is_success(), "idempotent command accepted once")
|
||||
var duplicate = _command(11, 1, 0, &"same-operation")
|
||||
var duplicate_result = queue.enqueue(duplicate)
|
||||
_expect(
|
||||
duplicate_result.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY,
|
||||
"duplicate idempotency key rejected"
|
||||
)
|
||||
queue.drain_ready(1)
|
||||
queue.complete(10, CommandResultType.succeeded(10, {"value": 42}))
|
||||
var retained = queue.get_result_by_idempotency_key(&"same-operation")
|
||||
_expect(retained != null and retained.details["value"] == 42, "processed result retained by key")
|
||||
_expect(retained.is_replay, "retained lookup is marked as replay")
|
||||
|
||||
|
||||
func _test_snapshot_preserves_accepted_idempotency_history() -> void:
|
||||
var queue = CommandQueueType.new()
|
||||
_expect(queue.enqueue(_command(1, 0, 10, &"processed-key")).is_success(), "snapshot fixture accepts keyed command")
|
||||
_expect(queue.enqueue(_command(2, 0, 5, &"")).is_success(), "snapshot fixture accepts keyless command")
|
||||
_expect(queue.enqueue(_command(3, 5, 1, &"pending-key")).is_success(), "snapshot fixture accepts pending keyed command")
|
||||
var completed: Array = queue.execute_ready(0, func(command: SimulationCommand) -> CommandResult:
|
||||
return CommandResultType.succeeded(command.command_id, {"completed": true})
|
||||
)
|
||||
_expect(completed.size() == 2, "snapshot fixture completes its ready commands")
|
||||
var snapshot: Dictionary = queue.create_snapshot()
|
||||
_expect(int(snapshot["version"]) == CommandQueueType.SNAPSHOT_VERSION, "Command Queue writes the current snapshot version")
|
||||
_expect(snapshot["accepted"] == [
|
||||
{"command_id": 1, "idempotency_key": "processed-key"},
|
||||
{"command_id": 2, "idempotency_key": ""},
|
||||
{"command_id": 3, "idempotency_key": "pending-key"},
|
||||
], "snapshot retains accepted metadata after Command inputs leave the queue")
|
||||
var restored = CommandQueueType.new()
|
||||
_expect(restored.restore_snapshot(snapshot), "strict current Command Queue snapshot restores")
|
||||
_expect(restored.create_snapshot() == snapshot, "current Command Queue snapshot round trip is exact")
|
||||
var metadata: Array[Dictionary] = restored.get_accepted_command_metadata()
|
||||
metadata[0]["idempotency_key"] = "changed"
|
||||
metadata.clear()
|
||||
_expect(restored.get_accepted_command_metadata() == snapshot["accepted"], "accepted metadata query is a defensive deep copy")
|
||||
var live_before: Dictionary = restored.create_snapshot()
|
||||
|
||||
var missing_map: Dictionary = snapshot.duplicate(true)
|
||||
missing_map["idempotency"].erase("processed-key")
|
||||
_expect(not restored.restore_snapshot(missing_map), "processed idempotency mapping deletion is rejected")
|
||||
_expect(restored.create_snapshot() == live_before, "mapping deletion rejection is restore-atomic")
|
||||
var changed_metadata: Dictionary = snapshot.duplicate(true)
|
||||
changed_metadata["accepted"][0]["idempotency_key"] = ""
|
||||
_expect(not restored.restore_snapshot(changed_metadata), "accepted metadata cannot disagree with the reverse map")
|
||||
_expect(restored.create_snapshot() == live_before, "metadata mismatch rejection is restore-atomic")
|
||||
var missing_metadata: Dictionary = snapshot.duplicate(true)
|
||||
missing_metadata["accepted"].remove_at(0)
|
||||
_expect(not restored.restore_snapshot(missing_metadata), "every known Command requires one accepted metadata row")
|
||||
var unordered_metadata: Dictionary = snapshot.duplicate(true)
|
||||
var first: Dictionary = unordered_metadata["accepted"][0]
|
||||
unordered_metadata["accepted"][0] = unordered_metadata["accepted"][1]
|
||||
unordered_metadata["accepted"][1] = first
|
||||
_expect(not restored.restore_snapshot(unordered_metadata), "accepted metadata must be ordered by Command ID")
|
||||
var unknown_field: Dictionary = snapshot.duplicate(true)
|
||||
unknown_field["extra"] = true
|
||||
_expect(not restored.restore_snapshot(unknown_field), "current snapshot rejects unknown root fields")
|
||||
var string_version: Dictionary = snapshot.duplicate(true)
|
||||
string_version["version"] = "2"
|
||||
_expect(not restored.restore_snapshot(string_version), "current snapshot rejects a coerced version")
|
||||
var float_result_id: Dictionary = snapshot.duplicate(true)
|
||||
float_result_id["processed"][0]["command_id"] = 1.0
|
||||
_expect(not restored.restore_snapshot(float_result_id), "current snapshot rejects a coerced processed Command ID")
|
||||
_expect(restored.create_snapshot() == live_before, "all malformed current snapshots preserve live queue state")
|
||||
|
||||
|
||||
func _test_legacy_snapshot_migrates_accepted_metadata() -> void:
|
||||
var source = CommandQueueType.new()
|
||||
source.enqueue(_command(7, 0, 1, &"legacy-processed"))
|
||||
source.drain_ready(0)
|
||||
source.complete(7, CommandResultType.succeeded(7))
|
||||
source.enqueue(_command(9, 20, 1, &"legacy-pending"))
|
||||
var legacy: Dictionary = source.create_snapshot()
|
||||
legacy.erase("version")
|
||||
legacy.erase("accepted")
|
||||
var restored = CommandQueueType.new()
|
||||
_expect(restored.restore_snapshot(legacy), "legacy unversioned Command Queue snapshot restores")
|
||||
var migrated: Dictionary = restored.create_snapshot()
|
||||
_expect(int(migrated["version"]) == CommandQueueType.SNAPSHOT_VERSION, "legacy restore writes the current version next time")
|
||||
_expect(migrated["accepted"] == [
|
||||
{"command_id": 7, "idempotency_key": "legacy-processed"},
|
||||
{"command_id": 9, "idempotency_key": "legacy-pending"},
|
||||
], "legacy reverse map reconstructs processed accepted idempotency metadata")
|
||||
_expect(restored.get_result_by_idempotency_key(&"legacy-processed") != null, "legacy processed key remains exactly-once after migration")
|
||||
|
||||
|
||||
func _test_transaction_commit() -> void:
|
||||
var state := {"gold": 100, "stock": 3}
|
||||
var transaction = TransactionType.new()
|
||||
transaction.stage_dictionary_increment(state, "gold", -40, 0)
|
||||
transaction.stage_dictionary_increment(state, "stock", -1, 0)
|
||||
_expect(state == {"gold": 100, "stock": 3}, "staging does not mutate state")
|
||||
var result = transaction.commit()
|
||||
_expect(result.is_success(), "valid transaction commits")
|
||||
_expect(state == {"gold": 60, "stock": 2}, "all staged changes commit together")
|
||||
|
||||
|
||||
func _test_validation_is_atomic() -> void:
|
||||
var state := {"gold": 100, "stock": 3}
|
||||
var transaction = TransactionType.new()
|
||||
transaction.stage_dictionary_set(state, "gold", 60)
|
||||
transaction.stage_dictionary_set(state, "stock", 2)
|
||||
transaction.add_validator(func() -> bool: return false)
|
||||
var result = transaction.commit()
|
||||
_expect(result.code == CommandResultType.Code.VALIDATION_FAILED, "validation failure reported")
|
||||
_expect(state == {"gold": 100, "stock": 3}, "validation failure applies no changes")
|
||||
|
||||
|
||||
func _test_apply_failure_rolls_back() -> void:
|
||||
var state := {"gold": 100, "stock": 3}
|
||||
var transaction = TransactionType.new()
|
||||
transaction.stage_dictionary_set(state, "gold", 60)
|
||||
transaction.stage_custom(
|
||||
"failing stock update",
|
||||
func() -> bool: return true,
|
||||
func() -> bool:
|
||||
state["stock"] = 2
|
||||
return false,
|
||||
func() -> void: state["stock"] = 3
|
||||
)
|
||||
var result = transaction.commit()
|
||||
_expect(
|
||||
result.code == CommandResultType.Code.TRANSACTION_APPLY_FAILED,
|
||||
"apply failure reported"
|
||||
)
|
||||
_expect(state == {"gold": 100, "stock": 3}, "applied changes roll back in reverse order")
|
||||
|
||||
|
||||
func _command(
|
||||
command_id: int,
|
||||
execute_tick: int,
|
||||
priority: int,
|
||||
idempotency_key: StringName
|
||||
):
|
||||
return CommandType.new(
|
||||
command_id,
|
||||
0,
|
||||
execute_tick,
|
||||
priority,
|
||||
&"Test",
|
||||
&"actor",
|
||||
&"target",
|
||||
{},
|
||||
idempotency_key
|
||||
)
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dulabdbgejdnn
|
||||
@@ -0,0 +1,291 @@
|
||||
extends SceneTree
|
||||
|
||||
const DefinitionRegistryScript := preload("res://scripts/core/definition_registry.gd")
|
||||
const FacilityCatalogScript := preload("res://scripts/data/facility_catalog.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_catalog_references()
|
||||
_test_expansion_catalog()
|
||||
_test_default_registry()
|
||||
_test_detailed_definition_schema()
|
||||
_test_invalid_detailed_service_is_rejected()
|
||||
_test_canonical_added_baselines()
|
||||
_test_queries()
|
||||
_test_duplicate_detection_is_deterministic()
|
||||
_test_missing_path_is_structured()
|
||||
if _failures == 0:
|
||||
print("PASS: DefinitionRegistry (%d checks)" % _checks)
|
||||
else:
|
||||
push_error("FAIL: DefinitionRegistry (%d of %d checks failed)" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_default_registry() -> void:
|
||||
var registry = DefinitionRegistryScript.new()
|
||||
var report: Dictionary = registry.rebuild()
|
||||
_expect(bool(report[&"valid"]), "all baseline definitions pass registry schema validation")
|
||||
_expect(int(report[&"source_count"]) == 16, "all sixteen baseline source files are inspected")
|
||||
_expect(int(report[&"definition_count"]) == 16, "all sixteen valid definitions are indexed")
|
||||
_expect(int(report[&"error_count"]) == 0, "baseline definitions have no fatal schema errors")
|
||||
_expect(int(report[&"warning_count"]) == 0, "baseline definitions have no schema warnings")
|
||||
var expected_ids: Array[StringName] = [
|
||||
&"adult_private_room",
|
||||
&"cafeteria",
|
||||
&"guest_corridor",
|
||||
&"guest_elevator",
|
||||
&"kitchen",
|
||||
&"massage_room",
|
||||
&"public_bath",
|
||||
&"reception",
|
||||
&"security_station",
|
||||
&"service_corridor",
|
||||
&"service_elevator",
|
||||
&"staff_door",
|
||||
&"staff_waiting_room",
|
||||
&"standard_room",
|
||||
&"tavern",
|
||||
&"warehouse",
|
||||
]
|
||||
_expect(
|
||||
registry.get_facility_ids() == expected_ids,
|
||||
"all sixteen facility IDs are returned in deterministic lexical order"
|
||||
)
|
||||
_expect(registry.has_facility(&"reception"), "has_facility finds a known ID")
|
||||
_expect(registry.has_facility(&"adult_private_room"), "has_facility finds the underground baseline ID")
|
||||
_expect(registry.get_facility(&"missing") == null, "unknown lookup returns null")
|
||||
|
||||
|
||||
func _test_catalog_references() -> void:
|
||||
_expect(FacilityCatalogScript.FACILITY_PATHS.size() == 16, "catalog exposes sixteen stable resource references")
|
||||
_expect(FacilityCatalogScript.BASELINE_FACILITY_IDS.size() == 16, "catalog exposes sixteen stable baseline IDs")
|
||||
var loaded: Array[FacilityDefinition] = FacilityCatalogScript.load_all()
|
||||
_expect(loaded.size() == 16, "every catalog resource reference resolves")
|
||||
var loaded_ids: Array[StringName] = []
|
||||
for index in FacilityCatalogScript.FACILITY_PATHS.size():
|
||||
var path: String = FacilityCatalogScript.FACILITY_PATHS[index]
|
||||
var expected_id: StringName = FacilityCatalogScript.BASELINE_FACILITY_IDS[index]
|
||||
_expect(FileAccess.file_exists(path), "catalog reference %s exists" % path)
|
||||
var definition := load(path) as FacilityDefinition
|
||||
_expect(definition != null, "catalog reference %s loads as FacilityDefinition" % path)
|
||||
if definition != null:
|
||||
loaded_ids.append(definition.id)
|
||||
_expect(definition.id == expected_id, "catalog reference %s preserves its expected ID" % path)
|
||||
_expect(loaded_ids == FacilityCatalogScript.BASELINE_FACILITY_IDS, "catalog loading preserves the design-table order")
|
||||
_expect(FacilityCatalogScript.by_id(&"service_elevator") != null, "catalog by_id resolves an added baseline")
|
||||
_expect(FacilityCatalogScript.by_id(&"not_a_facility") == null, "catalog by_id rejects an unknown reference")
|
||||
|
||||
|
||||
func _test_detailed_definition_schema() -> void:
|
||||
var registry = DefinitionRegistryScript.new()
|
||||
registry.rebuild()
|
||||
var reception: FacilityDefinition = registry.get_facility(&"reception")
|
||||
var kitchen: FacilityDefinition = registry.get_facility(&"kitchen")
|
||||
var restricted: FacilityDefinition = registry.get_facility(&"adult_private_room")
|
||||
_expect(reception.has_detailed_profile(), "reception uses the detailed facility schema")
|
||||
_expect(reception.default_service_type == &"check_in", "reception declares its default service")
|
||||
_expect(reception.service_profiles.size() == 2, "reception defines check-in and check-out profiles")
|
||||
_expect(reception.service_profiles[&"check_out"][&"duration_minutes"] == 6, "check-out has an independent duration")
|
||||
_expect(kitchen.has_detailed_profile(), "kitchen uses the detailed facility schema")
|
||||
_expect(kitchen.service_profiles[&"meal_batch"][&"capacity_units"] == 4, "kitchen service produces a four-unit batch")
|
||||
_expect(restricted.has_detailed_profile(), "restricted room uses the detailed facility schema")
|
||||
_expect(restricted.opening_schedule == {&"morning": false, &"day": false, &"night": true}, "restricted room is night-only")
|
||||
_expect(restricted.service_profiles[&"private_adult_service"][&"need_effects"][&"intimacy"] == -70, "restricted service records need relief")
|
||||
_expect(restricted.content_replacement_id == &"massage_room", "restricted service declares a safe replacement")
|
||||
|
||||
|
||||
func _test_invalid_detailed_service_is_rejected() -> void:
|
||||
var registry = DefinitionRegistryScript.new()
|
||||
var paths: Array[String] = ["res://tests/fixtures/facilities/invalid_service_profile.tres"]
|
||||
var report: Dictionary = registry.rebuild_from_paths(paths)
|
||||
var errors: Array[Dictionary] = registry.get_fatal_errors()
|
||||
_expect(not bool(report[&"valid"]), "an invalid detailed service profile makes the registry invalid")
|
||||
_expect(registry.facility_count() == 0, "an invalid detailed facility is not exposed through lookup")
|
||||
_expect(errors.size() == 1, "invalid service duration produces one focused issue")
|
||||
_expect(errors[0][&"code"] == &"invalid_service_profile_value", "invalid service duration has a stable issue code")
|
||||
_expect(errors[0][&"field"] == &"service_profiles", "invalid service issue identifies its owning field")
|
||||
|
||||
|
||||
func _test_expansion_catalog() -> void:
|
||||
_expect(FacilityCatalogScript.EXPANSION_FACILITY_IDS.size() == 16, "catalog exposes sixteen optional expansion IDs")
|
||||
_expect(FacilityCatalogScript.EXPANSION_FACILITY_PATHS.size() == 16, "catalog exposes sixteen optional expansion resources")
|
||||
var loaded: Array[FacilityDefinition] = FacilityCatalogScript.load_expansion()
|
||||
_expect(loaded.size() == 16, "every optional expansion resource resolves")
|
||||
var loaded_ids: Array[StringName] = []
|
||||
for index in FacilityCatalogScript.EXPANSION_FACILITY_PATHS.size():
|
||||
var path: String = FacilityCatalogScript.EXPANSION_FACILITY_PATHS[index]
|
||||
var expected_id: StringName = FacilityCatalogScript.EXPANSION_FACILITY_IDS[index]
|
||||
_expect(FileAccess.file_exists(path), "expansion catalog reference %s exists" % path)
|
||||
var definition := load(path) as FacilityDefinition
|
||||
_expect(definition != null, "expansion catalog reference %s loads" % path)
|
||||
if definition != null:
|
||||
loaded_ids.append(definition.id)
|
||||
_expect(definition.id == expected_id, "expansion resource preserves %s" % expected_id)
|
||||
_expect(definition.is_valid(), "expansion definition %s passes its resource schema" % expected_id)
|
||||
_expect(loaded_ids == FacilityCatalogScript.EXPANSION_FACILITY_IDS, "expansion loading preserves design order")
|
||||
_expect(FacilityCatalogScript.by_id(&"clinic") != null, "catalog by_id resolves an optional expansion")
|
||||
_expect(
|
||||
FacilityCatalogScript.by_id(&"vip_private_salon").zone == FacilityDefinition.MapZone.UNDERGROUND,
|
||||
"restricted expansion is constrained to the underground zone"
|
||||
)
|
||||
_expect(
|
||||
FacilityCatalogScript.by_id(&"mana_control_room").layer == FacilityDefinition.ViewLayer.BACK,
|
||||
"infrastructure expansion is constrained to the BACK layer"
|
||||
)
|
||||
|
||||
|
||||
func _test_canonical_added_baselines() -> void:
|
||||
var registry = DefinitionRegistryScript.new()
|
||||
registry.rebuild()
|
||||
var rows: Array = [
|
||||
[&"kitchen", 700, 10, Vector2i(4, 2), 1, 3, 20, 15, 0],
|
||||
[&"public_bath", 1200, 16, Vector2i(6, 2), 1, 2, 12, 60, 20],
|
||||
[&"staff_waiting_room", 400, 6, Vector2i(4, 2), 0, 0, 8, 1, 0],
|
||||
[&"security_station", 900, 10, Vector2i(2, 2), 1, 3, 3, 1, 0],
|
||||
[&"tavern", 1000, 12, Vector2i(4, 2), 2, 4, 14, 50, 40],
|
||||
[&"massage_room", 800, 8, Vector2i(2, 1), 1, 2, 2, 60, 100],
|
||||
[&"guest_elevator", 1500, 15, Vector2i(2, 2), 0, 0, 4, 10, 0],
|
||||
[&"service_elevator", 2200, 20, Vector2i(3, 2), 0, 0, 8, 5, 0],
|
||||
[&"staff_door", 100, 1, Vector2i(1, 1), 0, 0, 1, 1, 0],
|
||||
[&"guest_corridor", 20, 0, Vector2i(1, 1), 0, 0, 3, 1, 0],
|
||||
[&"service_corridor", 25, 0, Vector2i(1, 1), 0, 0, 2, 1, 0],
|
||||
[&"adult_private_room", 1500, 14, Vector2i(2, 1), 1, 1, 2, 90, 250],
|
||||
]
|
||||
for raw_row: Variant in rows:
|
||||
var row := raw_row as Array
|
||||
var facility_id: StringName = row[0]
|
||||
var definition: FacilityDefinition = registry.get_facility(facility_id)
|
||||
_expect(definition != null, "%s canonical baseline is registered" % facility_id)
|
||||
if definition == null:
|
||||
continue
|
||||
_expect(
|
||||
definition.construction_cost == {&"gold": row[1], &"materials": row[2]},
|
||||
"%s uses canonical Gold and Materials cost" % facility_id
|
||||
)
|
||||
_expect(definition.footprint == row[3], "%s uses canonical footprint" % facility_id)
|
||||
_expect(
|
||||
definition.staff_min == row[4] and definition.staff_max == row[5],
|
||||
"%s uses canonical Min/Max staffing" % facility_id
|
||||
)
|
||||
_expect(definition.capacity == row[6], "%s uses canonical capacity" % facility_id)
|
||||
_expect(definition.base_service_minutes == row[7], "%s uses canonical duration or one-minute proxy" % facility_id)
|
||||
_expect(definition.base_price == row[8], "%s uses canonical direct price" % facility_id)
|
||||
_expect(definition.is_valid(), "%s passes the FacilityDefinition schema" % facility_id)
|
||||
|
||||
var kitchen: FacilityDefinition = registry.get_facility(&"kitchen")
|
||||
_expect(kitchen.inventory_outputs.get(&"ingredient/basic_meal", 0) == 4, "kitchen models the canonical four-meal production batch")
|
||||
var restricted: FacilityDefinition = registry.get_facility(&"adult_private_room")
|
||||
_expect(restricted.zone == FacilityDefinition.MapZone.UNDERGROUND, "restricted private room is constrained to the underground zone")
|
||||
_expect(&"adult_only" in restricted.tags, "restricted private room carries an explicit adult-only systems guard tag")
|
||||
|
||||
|
||||
func _test_queries() -> void:
|
||||
var registry = DefinitionRegistryScript.new()
|
||||
registry.rebuild()
|
||||
var service_facilities: Array[StringName] = [
|
||||
&"adult_private_room", &"massage_room", &"public_bath", &"reception", &"tavern",
|
||||
]
|
||||
var any_zone: Array[StringName] = [
|
||||
&"cafeteria",
|
||||
&"guest_corridor",
|
||||
&"guest_elevator",
|
||||
&"kitchen",
|
||||
&"massage_room",
|
||||
&"public_bath",
|
||||
&"security_station",
|
||||
&"service_corridor",
|
||||
&"service_elevator",
|
||||
&"staff_door",
|
||||
&"staff_waiting_room",
|
||||
&"tavern",
|
||||
&"warehouse",
|
||||
]
|
||||
var back_facilities: Array[StringName] = [
|
||||
&"kitchen",
|
||||
&"security_station",
|
||||
&"service_corridor",
|
||||
&"service_elevator",
|
||||
&"staff_door",
|
||||
&"staff_waiting_room",
|
||||
&"warehouse",
|
||||
]
|
||||
_expect(
|
||||
_ids_of(registry.query_facilities_by_tag(&"service")) == service_facilities,
|
||||
"service tag query returns every matching definition in stable order"
|
||||
)
|
||||
_expect(
|
||||
_ids_of(registry.query_facilities_by_zone(FacilityDefinition.MapZone.ANY)) == any_zone,
|
||||
"zone query preserves ID order"
|
||||
)
|
||||
_expect(
|
||||
_ids_of(registry.query_facilities_by_zone(FacilityDefinition.MapZone.UNDERGROUND)) == [&"adult_private_room"],
|
||||
"underground query isolates its restricted baseline"
|
||||
)
|
||||
_expect(
|
||||
_ids_of(registry.query_facilities_by_layer(FacilityDefinition.ViewLayer.BACK)) == back_facilities,
|
||||
"BACK layer query returns all logistics and staff definitions"
|
||||
)
|
||||
_expect(
|
||||
_ids_of(registry.query_facilities_by_tag(&"corridor")) == [&"guest_corridor", &"service_corridor"],
|
||||
"corridor tag identifies both route-subslot proxies"
|
||||
)
|
||||
_expect(
|
||||
_ids_of(registry.query_facilities_by_tag(&"vertical_connector")) == [&"guest_elevator", &"service_elevator"],
|
||||
"vertical connector tag identifies both elevators"
|
||||
)
|
||||
_expect(
|
||||
_ids_of(registry.query_facilities_by_tag(&"cross_layer_connector")) == [&"staff_door"],
|
||||
"cross-layer connector query identifies the staff door proxy"
|
||||
)
|
||||
|
||||
|
||||
func _test_duplicate_detection_is_deterministic() -> void:
|
||||
var registry = DefinitionRegistryScript.new()
|
||||
var duplicate_paths: Array[String] = [
|
||||
"res://data/facilities/reception.tres",
|
||||
"res://data/facilities/reception.tres",
|
||||
]
|
||||
var first_report: Dictionary = registry.rebuild_from_paths(duplicate_paths)
|
||||
var first_errors: Array[Dictionary] = registry.get_fatal_errors()
|
||||
var second_report: Dictionary = registry.rebuild_from_paths(duplicate_paths)
|
||||
_expect(not bool(first_report[&"valid"]), "duplicate IDs make the registry invalid")
|
||||
_expect(int(first_report[&"error_count"]) == 2, "every duplicate source receives a fatal issue")
|
||||
_expect(registry.facility_count() == 0, "duplicate definitions are excluded from lookup")
|
||||
_expect(first_errors == registry.get_fatal_errors(), "issue order is deterministic across rebuilds")
|
||||
_expect(first_report[&"errors"][0][&"code"] == &"duplicate_id", "duplicate issue has a stable code")
|
||||
_expect(int(second_report[&"warning_count"]) == 1, "duplicate source paths produce a warning")
|
||||
|
||||
|
||||
func _test_missing_path_is_structured() -> void:
|
||||
var registry = DefinitionRegistryScript.new()
|
||||
var paths: Array[String] = ["res://data/facilities/does_not_exist.tres"]
|
||||
registry.rebuild_from_paths(paths)
|
||||
var errors: Array[Dictionary] = registry.get_fatal_errors()
|
||||
_expect(errors.size() == 1, "missing path produces one fatal issue")
|
||||
_expect(errors[0][&"severity"] == &"error", "issue carries severity")
|
||||
_expect(errors[0][&"code"] == &"resource_not_found", "missing path has a stable code")
|
||||
_expect(errors[0][&"field"] == &"resource_path", "issue identifies its field")
|
||||
_expect(errors[0][&"resource_path"] == paths[0], "issue identifies its source")
|
||||
|
||||
|
||||
func _ids_of(definitions: Array[FacilityDefinition]) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for definition in definitions:
|
||||
result.append(definition.id)
|
||||
return result
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwok3f6pmjar5
|
||||
@@ -0,0 +1,131 @@
|
||||
extends SceneTree
|
||||
|
||||
const DeterministicRngScript = preload("res://scripts/core/deterministic_rng.gd")
|
||||
const SimulationDigestScript = preload("res://scripts/core/simulation_digest.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S0: deterministic RNG and simulation digest")
|
||||
_test_stream_reproducibility()
|
||||
_test_stream_isolation()
|
||||
_test_snapshot_restore()
|
||||
_test_unbiased_range_contract()
|
||||
_test_digest_normalization()
|
||||
_test_digest_sequence_sensitivity()
|
||||
_test_digest_float_normalization()
|
||||
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_stream_reproducibility() -> void:
|
||||
var first = DeterministicRngScript.from_root_seed(20260827, &"guest_ai")
|
||||
var second = DeterministicRngScript.from_root_seed(20260827, &"guest_ai")
|
||||
var first_values: Array[int] = []
|
||||
var second_values: Array[int] = []
|
||||
for draw_index in range(64):
|
||||
first_values.append(first.next_u32())
|
||||
second_values.append(second.next_u32())
|
||||
_expect(first_values == second_values, "same root seed and stream ID reproduce the sequence")
|
||||
_expect(first.get_counter() == 64, "raw draw counter advances exactly once per sample")
|
||||
|
||||
|
||||
func _test_stream_isolation() -> void:
|
||||
var guest = DeterministicRngScript.from_root_seed(20260827, &"guest_ai")
|
||||
var events = DeterministicRngScript.from_root_seed(20260827, &"event_service")
|
||||
var guest_values: Array[int] = []
|
||||
var event_values: Array[int] = []
|
||||
for draw_index in range(16):
|
||||
guest_values.append(guest.next_u32())
|
||||
event_values.append(events.next_u32())
|
||||
_expect(guest_values != event_values, "different subsystem stream IDs derive isolated sequences")
|
||||
|
||||
|
||||
func _test_snapshot_restore() -> void:
|
||||
var original = DeterministicRngScript.from_root_seed(-17, &"authority_service")
|
||||
for draw_index in range(11):
|
||||
original.next_u32()
|
||||
var saved_state: Dictionary = original.snapshot()
|
||||
var expected_tail: Array[int] = []
|
||||
for draw_index in range(20):
|
||||
expected_tail.append(original.next_u32())
|
||||
|
||||
var restored = DeterministicRngScript.from_root_seed(0, &"temporary")
|
||||
_expect(restored.restore(saved_state), "valid RNG snapshot restores")
|
||||
var actual_tail: Array[int] = []
|
||||
for draw_index in range(20):
|
||||
actual_tail.append(restored.next_u32())
|
||||
_expect(actual_tail == expected_tail, "restored state continues at the exact next sample")
|
||||
_expect(restored.get_counter() == 31, "snapshot restores and continues the draw counter")
|
||||
|
||||
|
||||
func _test_unbiased_range_contract() -> void:
|
||||
var rng = DeterministicRngScript.from_root_seed(99, &"range_test")
|
||||
var all_samples_inside := true
|
||||
for draw_index in range(4096):
|
||||
var sample: int = rng.randi_range(-7, 13)
|
||||
all_samples_inside = all_samples_inside and sample >= -7 and sample <= 13
|
||||
_expect(all_samples_inside, "inclusive integer range never produces an out-of-range value")
|
||||
var counter_before_constant: int = rng.get_counter()
|
||||
_expect(rng.randi_range(5, 5) == 5, "single-value range returns that value")
|
||||
_expect(rng.get_counter() == counter_before_constant, "single-value range consumes no random sample")
|
||||
|
||||
|
||||
func _test_digest_normalization() -> void:
|
||||
var first := {}
|
||||
first[&"resources"] = {&"gold": 4500, &"mana": 100}
|
||||
first[&"minute"] = 721
|
||||
first[&"positions"] = [Vector2i(3, 4), Vector2i(-2, 8)]
|
||||
|
||||
var second := {}
|
||||
second[&"positions"] = [Vector2i(3, 4), Vector2i(-2, 8)]
|
||||
second[&"minute"] = 721
|
||||
var reversed_resources := {}
|
||||
reversed_resources[&"mana"] = 100
|
||||
reversed_resources[&"gold"] = 4500
|
||||
second[&"resources"] = reversed_resources
|
||||
|
||||
var first_digest: String = SimulationDigestScript.compute(first)
|
||||
var second_digest: String = SimulationDigestScript.compute(second)
|
||||
_expect(first_digest.length() == 64, "simulation digest is a 256-bit lowercase hexadecimal value")
|
||||
_expect(first_digest == second_digest, "dictionary insertion order does not affect the digest")
|
||||
|
||||
|
||||
func _test_digest_sequence_sensitivity() -> void:
|
||||
var forward: String = SimulationDigestScript.compute([1, 2, 3])
|
||||
var reversed: String = SimulationDigestScript.compute([3, 2, 1])
|
||||
_expect(
|
||||
forward == "4e32bb2921e1b879d5b8778789d3bc7e46559d4448e95f9c2fed427d7e5c7b93",
|
||||
"canonical digest format matches its versioned reference vector"
|
||||
)
|
||||
_expect(forward != reversed, "array order remains significant")
|
||||
_expect(
|
||||
SimulationDigestScript.compute({"value": 1}) != SimulationDigestScript.compute({"value": "1"}),
|
||||
"value types remain significant"
|
||||
)
|
||||
|
||||
|
||||
func _test_digest_float_normalization() -> void:
|
||||
_expect(
|
||||
SimulationDigestScript.compute({"zero": 0.0}) == SimulationDigestScript.compute({"zero": -0.0}),
|
||||
"positive and negative floating-point zero share one canonical representation"
|
||||
)
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://6ob7pdgspn68
|
||||
@@ -0,0 +1,121 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
print("S0: domain events")
|
||||
_test_single_publish_and_snapshot()
|
||||
_test_atomic_batch_and_reentrant_delivery()
|
||||
_test_batch_rejection_is_atomic()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_single_publish_and_snapshot() -> void:
|
||||
var stream := DomainEventStream.new()
|
||||
var delivery_order: Array[StringName] = []
|
||||
stream.subscribe(&"SERVICE_COMPLETED", &"z_report", func(_event: SimulationDomainEvent) -> void: delivery_order.append(&"z_report"))
|
||||
stream.subscribe(&"*", &"a_log", func(_event: SimulationDomainEvent) -> void: delivery_order.append(&"a_log"))
|
||||
var event := SimulationDomainEvent.new(
|
||||
&"domain_event.000000000001",
|
||||
&"SERVICE_COMPLETED",
|
||||
360,
|
||||
&"facility_service",
|
||||
[&"facility.000000000001"],
|
||||
{&"quality": 80},
|
||||
12
|
||||
)
|
||||
_expect(event.is_valid(), "complete domain event is valid")
|
||||
_expect(stream.publish(event), "new fact is published")
|
||||
_expect(delivery_order == [&"a_log", &"z_report"], "subscribers run in stable ID order")
|
||||
_expect(not stream.publish(event), "same event ID cannot be applied twice")
|
||||
_expect(delivery_order.size() == 2, "duplicate event is not redelivered")
|
||||
var snapshot := stream.create_snapshot()
|
||||
var restored := DomainEventStream.new()
|
||||
_expect(restored.restore_snapshot(snapshot), "published event state restores")
|
||||
_expect(restored.has_published(event.event_id), "deduplication IDs survive restore")
|
||||
var recent := restored.recent_events()
|
||||
_expect(recent.size() == 1 and recent[0].payload[&"quality"] == 80, "recent fact data survives restore")
|
||||
|
||||
|
||||
func _test_atomic_batch_and_reentrant_delivery() -> void:
|
||||
var stream := DomainEventStream.new()
|
||||
var deliveries: Array[StringName] = []
|
||||
var callback_probe := {
|
||||
&"second_committed_before_delivery": false,
|
||||
&"restore_rejected_during_delivery": false,
|
||||
}
|
||||
var event_a := _event(&"domain_event.batch.0001", &"A", 400)
|
||||
var event_b := _event(&"domain_event.batch.0002", &"B", 400)
|
||||
var event_c := _event(&"domain_event.batch.0003", &"C", 400)
|
||||
var snapshot_before := stream.create_snapshot()
|
||||
stream.subscribe(&"*", &"observer", func(event: SimulationDomainEvent) -> void:
|
||||
deliveries.append(event.event_type)
|
||||
if event.event_type == &"A":
|
||||
callback_probe[&"second_committed_before_delivery"] = stream.has_published(event_b.event_id)
|
||||
callback_probe[&"restore_rejected_during_delivery"] = not stream.restore_snapshot(snapshot_before)
|
||||
_expect(stream.publish_batch([event_c]), "reentrant publication is accepted after the committed batch")
|
||||
)
|
||||
_expect(stream.publish_batch([event_a, event_b]), "valid event batch commits")
|
||||
_expect(bool(callback_probe[&"second_committed_before_delivery"]), "every batch ID is committed before the first callback")
|
||||
_expect(bool(callback_probe[&"restore_rejected_during_delivery"]), "subscriber cannot replace stream state during delivery")
|
||||
_expect(deliveries == [&"A", &"B", &"C"], "reentrant event delivery waits behind the complete batch")
|
||||
_expect(
|
||||
stream.has_published(event_a.event_id) \
|
||||
and stream.has_published(event_b.event_id) \
|
||||
and stream.has_published(event_c.event_id),
|
||||
"batch and reentrant event IDs are all retained"
|
||||
)
|
||||
stream.unsubscribe(&"*", &"observer")
|
||||
|
||||
|
||||
func _test_batch_rejection_is_atomic() -> void:
|
||||
var stream := DomainEventStream.new()
|
||||
var deliveries: Array[StringName] = []
|
||||
stream.subscribe(&"*", &"observer", func(event: SimulationDomainEvent) -> void:
|
||||
deliveries.append(event.event_type)
|
||||
)
|
||||
var valid := _event(&"domain_event.reject.0001", &"VALID", 500)
|
||||
var duplicate := _event(&"domain_event.reject.0001", &"DUPLICATE", 500)
|
||||
var before := stream.create_snapshot()
|
||||
_expect(not stream.publish_batch([valid, duplicate]), "duplicate ID inside a batch is rejected")
|
||||
_expect(stream.create_snapshot() == before and deliveries.is_empty(), "duplicate batch rejection has no state or notification prefix")
|
||||
var invalid := _event(&"domain_event.reject.0002", &"INVALID", 500)
|
||||
invalid.event_type = &""
|
||||
_expect(not stream.publish_batch([valid, invalid]), "invalid member rejects the whole batch")
|
||||
_expect(stream.create_snapshot() == before and deliveries.is_empty(), "invalid batch rejection remains atomic")
|
||||
_expect(stream.publish(valid), "single publish still delegates to the batch boundary")
|
||||
var after_valid := stream.create_snapshot()
|
||||
_expect(not stream.publish_batch([_event(&"domain_event.reject.0003", &"NEW", 500), valid]), "collision with published history rejects the whole batch")
|
||||
_expect(stream.create_snapshot() == after_valid and deliveries == [&"VALID"], "history collision cannot commit a new prefix")
|
||||
var oversized: Array = []
|
||||
oversized.resize(DomainEventStream.MAX_BATCH_SIZE + 1)
|
||||
_expect(not stream.publish_batch(oversized), "oversized batch is rejected before member access")
|
||||
_expect(stream.create_snapshot() == after_valid, "oversized rejection preserves history")
|
||||
|
||||
|
||||
func _event(event_id: StringName, event_type: StringName, minute: int) -> SimulationDomainEvent:
|
||||
return SimulationDomainEvent.new(
|
||||
event_id,
|
||||
event_type,
|
||||
minute,
|
||||
&"test",
|
||||
[],
|
||||
{},
|
||||
0
|
||||
)
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://3a3nnlur4gv3
|
||||
@@ -0,0 +1,123 @@
|
||||
extends RefCounted
|
||||
|
||||
static func run(expect: Callable) -> void:
|
||||
_test_fixed_tick_order(expect)
|
||||
_test_frame_budget_and_overload(expect)
|
||||
_test_boundary_pause_stops_catch_up(expect)
|
||||
_test_apply_gate_aborts_before_clock_increment(expect)
|
||||
_test_snapshot_restore(expect)
|
||||
_test_save_round_trip(expect)
|
||||
|
||||
static func _test_fixed_tick_order(expect: Callable) -> void:
|
||||
var clock = load("res://autoload/game_clock.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
var phases: Array[int] = []
|
||||
clock.tick_phase_started.connect(func(phase: int, _minute: int) -> void: phases.append(phase))
|
||||
var handler_order: Array[StringName] = []
|
||||
clock.register_tick_handler(clock.TickPhase.MANA, &"z_last", func(_minute: int) -> void: handler_order.append(&"z_last"))
|
||||
clock.register_tick_handler(clock.TickPhase.MANA, &"a_first", func(_minute: int) -> void: handler_order.append(&"a_first"))
|
||||
clock.advance_one_minute()
|
||||
expect.call(clock.absolute_minute == 361, "fixed tick advances exactly one game minute")
|
||||
expect.call(phases == range(clock.TickPhase.size()), "tick phases run in the specified order")
|
||||
expect.call(handler_order == [&"a_first", &"z_last"], "phase handlers use stable id order")
|
||||
clock.free()
|
||||
|
||||
static func _test_frame_budget_and_overload(expect: Callable) -> void:
|
||||
var clock = load("res://autoload/game_clock.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
var processed: int = clock.process_real_delta(5.0)
|
||||
expect.call(processed == clock.MAX_TICKS_PER_FRAME, "one frame runs at most eight ticks")
|
||||
expect.call(clock.absolute_minute == 368, "excess ticks remain queued instead of being skipped")
|
||||
clock.reset(1, 6, 0)
|
||||
clock.process_real_delta(100.0)
|
||||
expect.call(clock.paused and clock.get_backlog_minutes() > 120, "large backlog pauses the simulation")
|
||||
clock.free()
|
||||
|
||||
static func _test_boundary_pause_stops_catch_up(expect: Callable) -> void:
|
||||
var clock = load("res://autoload/game_clock.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
clock.register_tick_handler(
|
||||
clock.TickPhase.TIME_BOUNDARIES,
|
||||
&"test.pause_boundary",
|
||||
func(minute: int) -> void:
|
||||
if minute == 361:
|
||||
clock.set_paused(true)
|
||||
)
|
||||
var processed: int = clock.process_real_delta(5.0)
|
||||
expect.call(processed == 1, "a boundary pause stops the remaining frame tick budget")
|
||||
expect.call(clock.absolute_minute == 361, "catch-up cannot advance beyond the pausing boundary")
|
||||
clock.reset(1, 6, 0)
|
||||
clock.advance_minutes(8)
|
||||
expect.call(clock.absolute_minute == 361, "an initially-running manual batch stops when a handler pauses")
|
||||
clock.unregister_tick_handler(clock.TickPhase.TIME_BOUNDARIES, &"test.pause_boundary")
|
||||
clock.reset(1, 6, 0)
|
||||
clock.set_paused(true)
|
||||
clock.advance_minutes(2)
|
||||
expect.call(clock.absolute_minute == 362, "an already-paused clock retains explicit manual stepping")
|
||||
clock.free()
|
||||
|
||||
static func _test_apply_gate_aborts_before_clock_increment(expect: Callable) -> void:
|
||||
var clock = load("res://autoload/game_clock.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
clock.register_tick_handler(
|
||||
clock.TickPhase.APPLY_COMMANDS,
|
||||
&"test.abort_tick",
|
||||
func(_minute: int) -> void:
|
||||
clock.set_paused(true)
|
||||
clock.request_current_tick_abort()
|
||||
)
|
||||
var starting_version: int = clock.snapshot_version
|
||||
var processed: int = clock.process_real_delta(5.0)
|
||||
expect.call(processed == 0, "an APPLY gate abort consumes no catch-up tick")
|
||||
expect.call(
|
||||
clock.absolute_minute == 360 and clock.snapshot_version == starting_version,
|
||||
"an APPLY gate abort leaves Clock minute and snapshot version exact"
|
||||
)
|
||||
clock.advance_minutes(1)
|
||||
expect.call(
|
||||
clock.absolute_minute == 360 and clock.snapshot_version == starting_version,
|
||||
"manual stepping cannot bypass an APPLY gate abort"
|
||||
)
|
||||
clock.free()
|
||||
|
||||
static func _test_snapshot_restore(expect: Callable) -> void:
|
||||
var clock = load("res://autoload/game_clock.gd").new()
|
||||
var state = load("res://autoload/game_state.gd").new()
|
||||
clock.reset(2, 5, 30)
|
||||
state.reset()
|
||||
var starting_revision: int = state.revision
|
||||
expect.call(state.apply_resource_deltas({&"gold": -250, &"materials": -5}), "multi-resource transaction succeeds")
|
||||
expect.call(state.revision == starting_revision + 1, "atomic resource transaction creates one revision")
|
||||
var before_failed: Dictionary = state.create_snapshot()
|
||||
expect.call(not state.apply_resource_deltas({&"gold": -999999, &"mana": 10}), "invalid transaction is rejected")
|
||||
expect.call(state.create_snapshot() == before_failed, "rejected transaction changes no resource")
|
||||
|
||||
var snapshot := SimulationSnapshot.capture(clock, state)
|
||||
clock.advance_minutes(10)
|
||||
state.add_resource(&"gold", 999)
|
||||
expect.call(SimulationSnapshot.restore(snapshot, clock, state), "valid snapshot restores atomically")
|
||||
expect.call(clock.absolute_minute == int(snapshot[&"clock"][&"absolute_minute"]), "clock snapshot restores exact minute")
|
||||
expect.call(state.create_snapshot() == snapshot[&"game_state"], "game state snapshot restores exactly")
|
||||
var minute_before_skip: int = clock.absolute_minute
|
||||
expect.call(clock.process_real_delta(1.0) == 0 and clock.absolute_minute == minute_before_skip, "first frame after load does not advance")
|
||||
clock.free()
|
||||
state.free()
|
||||
|
||||
static func _test_save_round_trip(expect: Callable) -> void:
|
||||
var save_path := "user://saves/s0_round_trip"
|
||||
SaveService.delete_save(save_path)
|
||||
var clock = load("res://autoload/game_clock.gd").new()
|
||||
var state = load("res://autoload/game_state.gd").new()
|
||||
clock.reset(3, 18, 45)
|
||||
state.reset()
|
||||
state.apply_resource_deltas({&"gold": -700, &"mana": 25})
|
||||
var snapshot := SimulationSnapshot.capture(clock, state, {&"next_entity_id": 42})
|
||||
var save_result := SaveService.save_snapshot(save_path, snapshot, {&"display_name": "S0 Round Trip"})
|
||||
expect.call(save_result[&"ok"], "snapshot is written atomically")
|
||||
var load_result := SaveService.load_snapshot(save_path)
|
||||
expect.call(load_result[&"ok"], "saved snapshot passes checksum and schema validation")
|
||||
if load_result[&"ok"]:
|
||||
expect.call(load_result[&"snapshot"] == snapshot, "save and load preserve the complete snapshot")
|
||||
SaveService.delete_save(save_path)
|
||||
clock.free()
|
||||
state.free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://w4p6kwpj1x46
|
||||
@@ -0,0 +1,201 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _runtime: Variant
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
print("S0: runtime integration")
|
||||
_test_tick_command_execution()
|
||||
_test_starter_world_layers()
|
||||
_test_paused_command_execution()
|
||||
_test_runtime_snapshot_restore()
|
||||
_test_runtime_save_round_trip()
|
||||
_test_twenty_four_hour_determinism()
|
||||
_cleanup_runtime()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
func _reset_runtime(seed := 20260827) -> void:
|
||||
_cleanup_runtime()
|
||||
_clock = load("res://autoload/game_clock.gd").new()
|
||||
_state = load("res://autoload/game_state.gd").new()
|
||||
_runtime = load("res://autoload/simulation_runtime.gd").new()
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_runtime.initialize(_clock, _state, seed)
|
||||
|
||||
func _cleanup_runtime() -> void:
|
||||
if _runtime != null:
|
||||
_runtime.free()
|
||||
if _clock != null:
|
||||
_clock.free()
|
||||
if _state != null:
|
||||
_state.free()
|
||||
_runtime = null
|
||||
_clock = null
|
||||
_state = null
|
||||
|
||||
func _test_tick_command_execution() -> void:
|
||||
_reset_runtime()
|
||||
var initial_gold: int = _state.get_resource(&"gold")
|
||||
var result = _runtime.submit_command(
|
||||
&"spend_resources",
|
||||
{&"cost": {&"gold": 100}},
|
||||
&"player",
|
||||
&"",
|
||||
50,
|
||||
_clock.absolute_minute + 5,
|
||||
&"integration.spend.future"
|
||||
)
|
||||
_expect(result.is_success(), "future resource command enters the queue")
|
||||
_clock.advance_minutes(4)
|
||||
_expect(_state.get_resource(&"gold") == initial_gold, "future command does not execute early")
|
||||
_clock.advance_one_minute()
|
||||
_expect(_state.get_resource(&"gold") == initial_gold - 100, "command executes on its scheduled minute")
|
||||
var processed = _runtime.command_queue.get_result_by_idempotency_key(&"integration.spend.future")
|
||||
_expect(processed != null and processed.is_success(), "processed result remains available by idempotency key")
|
||||
|
||||
func _test_starter_world_layers() -> void:
|
||||
_reset_runtime()
|
||||
var front_start := Vector3i(-2, 0, WorldService.LAYER_FRONT)
|
||||
var back_goal := Vector3i(2, 0, WorldService.LAYER_BACK)
|
||||
var guest_path: Array[Vector3i] = _runtime.path_service.find_path(
|
||||
front_start, back_goal, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK]
|
||||
)
|
||||
_expect(guest_path.is_empty(), "guest access cannot cross the FRONT/BACK staff door")
|
||||
var staff_path: Array[Vector3i] = _runtime.path_service.find_path(
|
||||
front_start,
|
||||
back_goal,
|
||||
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK],
|
||||
[&"staff"]
|
||||
)
|
||||
_expect(not staff_path.is_empty(), "staff access crosses the explicit FRONT/BACK connector")
|
||||
|
||||
func _test_paused_command_execution() -> void:
|
||||
_reset_runtime()
|
||||
_clock.set_paused(true)
|
||||
var minute_before: int = _clock.absolute_minute
|
||||
var result = _runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"faith": 7}},
|
||||
&"player",
|
||||
&"",
|
||||
0,
|
||||
-1,
|
||||
&"integration.paused.faith"
|
||||
)
|
||||
_expect(result.is_success() and _state.get_resource(&"faith") == 7, "current-minute command can complete while paused")
|
||||
_expect(_clock.absolute_minute == minute_before, "paused command does not advance simulation time")
|
||||
var duplicate = _runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"faith": 7}},
|
||||
&"player",
|
||||
&"",
|
||||
0,
|
||||
-1,
|
||||
&"integration.paused.faith"
|
||||
)
|
||||
_expect(duplicate.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, "duplicate idempotency key cannot apply twice")
|
||||
|
||||
func _test_runtime_snapshot_restore() -> void:
|
||||
_reset_runtime(77)
|
||||
var authority_rng = _runtime.get_rng_stream(&"authority")
|
||||
for _index in range(9):
|
||||
authority_rng.next_u32()
|
||||
_runtime.submit_command(
|
||||
&"spend_resources",
|
||||
{&"cost": {&"materials": 3}},
|
||||
&"player",
|
||||
&"",
|
||||
0,
|
||||
_clock.absolute_minute + 30,
|
||||
&"integration.pending.materials"
|
||||
)
|
||||
var snapshot: Dictionary = _runtime.create_snapshot()
|
||||
var expected_digest := SimulationDigest.compute(snapshot)
|
||||
var expected_rng_tail: int = authority_rng.next_u32()
|
||||
_clock.advance_minutes(12)
|
||||
_state.add_resource(&"gold", 500)
|
||||
_expect(_runtime.restore_snapshot(snapshot), "runtime restores clock, state, queue and RNG streams")
|
||||
_expect(_runtime.latest_digest == expected_digest, "restored runtime recreates the exact digest")
|
||||
_expect(_runtime.get_rng_stream(&"authority").next_u32() == expected_rng_tail, "restored RNG resumes at the exact next draw")
|
||||
_clock.advance_minutes(30)
|
||||
_expect(_state.get_resource(&"materials") == 47, "restored pending command executes once")
|
||||
|
||||
func _test_twenty_four_hour_determinism() -> void:
|
||||
var first_snapshot := _run_day_scenario(99117)
|
||||
var second_snapshot := _run_day_scenario(99117)
|
||||
var first_digest := SimulationDigest.compute(first_snapshot)
|
||||
var second_digest := SimulationDigest.compute(second_snapshot)
|
||||
if first_digest != second_digest:
|
||||
_report_digest_difference(first_snapshot, second_snapshot)
|
||||
_expect(first_digest.length() == 64, "24-hour scenario produces a simulation digest")
|
||||
_expect(first_digest == second_digest, "same seed and command sequence match after 24 game hours")
|
||||
|
||||
func _test_runtime_save_round_trip() -> void:
|
||||
var save_path := "user://saves/s0_runtime_round_trip"
|
||||
SaveService.delete_save(save_path)
|
||||
_reset_runtime(54321)
|
||||
_runtime.get_rng_stream(&"event").next_u32()
|
||||
_runtime.submit_command(
|
||||
&"spend_resources",
|
||||
{&"cost": {&"gold": 333}},
|
||||
&"player", &"", 0, _clock.absolute_minute + 20, &"runtime.save.pending"
|
||||
)
|
||||
var expected_digest := SimulationDigest.compute(_runtime.create_snapshot())
|
||||
var save_result: Dictionary = _runtime.save_to_path(save_path, {&"display_name": "Runtime Round Trip"})
|
||||
_expect(save_result[&"ok"], "runtime snapshot writes command queue and RNG state to disk")
|
||||
_clock.advance_minutes(25)
|
||||
_state.add_resource(&"gold", 999)
|
||||
var load_result: Dictionary = _runtime.load_from_path(save_path)
|
||||
_expect(load_result[&"ok"], "runtime snapshot loads from verified world data")
|
||||
_expect(_runtime.latest_digest == expected_digest, "disk round trip restores the exact runtime digest")
|
||||
_clock.advance_minutes(20)
|
||||
_expect(_state.get_resource(&"gold") == 4667, "disk-restored pending command executes exactly once")
|
||||
SaveService.delete_save(save_path)
|
||||
|
||||
func _run_day_scenario(seed: int) -> Dictionary:
|
||||
_reset_runtime(seed)
|
||||
var guest_rng = _runtime.get_rng_stream(&"guest_spawn")
|
||||
for _index in range(16):
|
||||
guest_rng.randi_range(1, 100)
|
||||
_runtime.submit_command(
|
||||
&"spend_resources",
|
||||
{&"cost": {&"gold": 250, &"materials": 2}},
|
||||
&"player", &"", 20, 6 * 60 + 60, &"scenario.build"
|
||||
)
|
||||
_runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"mana": 15, &"faith": 3}},
|
||||
&"event", &"", 10, 6 * 60 + 12 * 60, &"scenario.reward"
|
||||
)
|
||||
_clock.advance_minutes(24 * 60)
|
||||
return _runtime.latest_snapshot.duplicate(true)
|
||||
|
||||
func _report_digest_difference(first: Dictionary, second: Dictionary) -> void:
|
||||
var first_runtime: Dictionary = first[&"systems"][&"runtime"]
|
||||
var second_runtime: Dictionary = second[&"systems"][&"runtime"]
|
||||
var keys: Array = first_runtime.keys()
|
||||
keys.sort()
|
||||
for key: Variant in keys:
|
||||
var first_part := SimulationDigest.compute(first_runtime[key])
|
||||
var second_part := SimulationDigest.compute(second_runtime.get(key))
|
||||
if first_part != second_part:
|
||||
print(" digest mismatch in runtime.%s: %s != %s" % [key, first_part, second_part])
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://brilcgnxebg7u
|
||||
@@ -0,0 +1,35 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
print("S0: stable ID generator")
|
||||
var generator := StableIdGenerator.new()
|
||||
_expect(generator.next_id(&"guest") == &"guest.000000000001", "first ID uses a stable padded sequence")
|
||||
_expect(generator.next_id(&"guest") == &"guest.000000000002", "same prefix increments monotonically")
|
||||
_expect(generator.next_id(&"staff") == &"staff.000000000001", "prefixes have independent counters")
|
||||
var snapshot := generator.create_snapshot()
|
||||
var restored := StableIdGenerator.new()
|
||||
_expect(restored.restore_snapshot(snapshot), "valid ID counters restore")
|
||||
_expect(restored.next_id(&"guest") == &"guest.000000000003", "restored counter never reuses an ID")
|
||||
_expect(restored.next_id(&"staff") == &"staff.000000000002", "all prefix counters restore")
|
||||
var before_invalid := restored.create_snapshot()
|
||||
_expect(not restored.restore_snapshot({"version": 1, "counters": {"Bad Prefix": 2}}), "invalid prefix snapshot is rejected")
|
||||
_expect(restored.create_snapshot() == before_invalid, "rejected restore leaves counters unchanged")
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfgmvvsmbwg8t
|
||||
Reference in New Issue
Block a user