feat: add emergent world foundations
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_core_pack()
|
||||
_test_deterministic_enumeration_and_dependencies()
|
||||
_test_duplicate_ids_fail_without_overrides()
|
||||
_test_unknown_references_categories_and_handlers()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_core_pack() -> void:
|
||||
var catalog := ContentCatalog.create_core()
|
||||
_check(catalog.is_valid(), "Core pack should validate: %s" % [catalog.get_errors()])
|
||||
_check(
|
||||
(
|
||||
_action_ids(catalog.get_actions())
|
||||
== [
|
||||
&"defend",
|
||||
&"deposit_food",
|
||||
&"deposit_wood",
|
||||
&"eat",
|
||||
&"feed_animal",
|
||||
&"gather_food",
|
||||
&"gather_wood",
|
||||
&"patrol",
|
||||
&"rest",
|
||||
&"sleep",
|
||||
&"study",
|
||||
&"wander",
|
||||
&"withdraw_food",
|
||||
]
|
||||
),
|
||||
"Core actions should enumerate by stable ID"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
_profession_ids(catalog.get_professions())
|
||||
== [&"farmer", &"guard", &"scholar", &"wanderer", &"woodcutter"]
|
||||
),
|
||||
"Core professions should enumerate by stable ID"
|
||||
)
|
||||
_check(
|
||||
_item_ids(catalog.get_items()) == [&"food", &"item_claw", &"item_sword", &"wood"],
|
||||
"Core items should include food, wood, sword, and claw in stable-ID order"
|
||||
)
|
||||
_check(
|
||||
_enemy_ids(catalog.get_enemies()) == [&"enemy_raider", &"enemy_wolf"],
|
||||
"Core enemies should enumerate by stable ID"
|
||||
)
|
||||
var food := catalog.get_item(SimulationIds.RESOURCE_FOOD)
|
||||
var wood := catalog.get_item(SimulationIds.RESOURCE_WOOD)
|
||||
var sword := catalog.get_item(SimulationIds.ITEM_SWORD)
|
||||
var claw := catalog.get_item(SimulationIds.ITEM_CLAW)
|
||||
_check(food != null and food.is_resource(), "Food should be a resource item")
|
||||
_check(wood != null and wood.is_resource(), "Wood should be a resource item")
|
||||
_check(
|
||||
sword != null and sword.is_weapon() and is_equal_approx(sword.damage, 24.0),
|
||||
"Sword should preserve its weapon contract"
|
||||
)
|
||||
_check(
|
||||
claw != null and claw.is_weapon() and is_equal_approx(claw.reach, 1.2),
|
||||
"Claw should preserve its weapon contract"
|
||||
)
|
||||
var wolf := catalog.get_enemy(&"enemy_wolf")
|
||||
_check(
|
||||
wolf != null and wolf.weapon_id == SimulationIds.ITEM_CLAW,
|
||||
"Enemy references should resolve against catalog items"
|
||||
)
|
||||
|
||||
|
||||
func _test_deterministic_enumeration_and_dependencies() -> void:
|
||||
var core := load(ContentCatalog.CORE_PACK_PATH) as SimulationContentPack
|
||||
var addon := _pack(&"addon", "Addon")
|
||||
addon.required_pack_ids = [&"core"]
|
||||
addon.required_handler_ids = [&"test_handler"]
|
||||
addon.items = [_resource_item(&"amber", "Amber")]
|
||||
var packs: Array[SimulationContentPack] = [addon, core]
|
||||
var catalog := ContentCatalog.new()
|
||||
var handlers: Array[StringName] = [&"test_handler"]
|
||||
var errors := catalog.rebuild(packs, handlers)
|
||||
_check(errors.is_empty(), "Known pack and handler dependencies should validate: %s" % [errors])
|
||||
_check(
|
||||
_pack_ids(catalog.get_packs()) == [&"addon", &"core"],
|
||||
"Pack enumeration should not depend on input order"
|
||||
)
|
||||
_check(
|
||||
_item_ids(catalog.get_items()) == [&"amber", &"food", &"item_claw", &"item_sword", &"wood"],
|
||||
"Merged item enumeration should be globally stable-ID sorted"
|
||||
)
|
||||
var returned_items := catalog.get_items()
|
||||
returned_items.clear()
|
||||
_check(
|
||||
catalog.get_items().size() == 5,
|
||||
"Callers should not be able to mutate the catalog's ordered arrays"
|
||||
)
|
||||
|
||||
|
||||
func _test_duplicate_ids_fail_without_overrides() -> void:
|
||||
var first := _pack(&"first", "First")
|
||||
first.items = [_resource_item(&"shared_item", "First value")]
|
||||
var second := _pack(&"second", "Second")
|
||||
second.items = [_resource_item(&"shared_item", "Second value")]
|
||||
var packs: Array[SimulationContentPack] = [second, first]
|
||||
var catalog := ContentCatalog.new()
|
||||
var errors := catalog.rebuild(packs)
|
||||
_check(_has_error(errors, "Duplicate item_id 'shared_item'"), "Duplicate item IDs should fail")
|
||||
_check(
|
||||
(
|
||||
not catalog.is_valid()
|
||||
and catalog.get_item(&"shared_item") == null
|
||||
and catalog.get_items().is_empty()
|
||||
),
|
||||
"An invalid build should publish neither duplicate value nor partial content"
|
||||
)
|
||||
|
||||
var duplicate_pack := _pack(&"first", "Duplicate pack")
|
||||
var duplicate_packs: Array[SimulationContentPack] = [first, duplicate_pack]
|
||||
errors = catalog.rebuild(duplicate_packs)
|
||||
_check(_has_error(errors, "Duplicate pack_id 'first'"), "Duplicate pack IDs should fail")
|
||||
|
||||
|
||||
func _test_unknown_references_categories_and_handlers() -> void:
|
||||
var broken := _pack(&"broken", "Broken")
|
||||
broken.required_pack_ids = [&"missing_pack"]
|
||||
broken.required_handler_ids = [&"missing_handler"]
|
||||
var invalid_item := _resource_item(&"strange_item", "Strange")
|
||||
invalid_item.category = &"unknown_category"
|
||||
var ordinary_resource := _resource_item(&"ordinary_resource", "Ordinary resource")
|
||||
var missing_profession := _action(&"missing_profession")
|
||||
missing_profession.preferred_profession_id = &"unknown_profession"
|
||||
var missing_action := _action(&"missing_action")
|
||||
missing_action.target_type = SimulationIds.TARGET_RESOURCE
|
||||
missing_action.resource_action_id = &"unknown_resource_action"
|
||||
var missing_cost_item := _action(&"missing_cost")
|
||||
missing_cost_item.completion_cost_resource_id = &"unknown_cost_item"
|
||||
missing_cost_item.completion_cost_amount = 1.0
|
||||
var weapon_item := _weapon_item(&"test_weapon", "Test weapon")
|
||||
var weapon_cost := _action(&"weapon_cost")
|
||||
weapon_cost.completion_cost_resource_id = weapon_item.item_id
|
||||
weapon_cost.completion_cost_amount = 1.0
|
||||
var missing_weapon := _enemy(&"missing_weapon_enemy", &"unknown_weapon")
|
||||
var non_weapon := _enemy(&"non_weapon_enemy", ordinary_resource.item_id)
|
||||
broken.items = [invalid_item, ordinary_resource, weapon_item]
|
||||
broken.actions = [missing_profession, missing_action, missing_cost_item, weapon_cost]
|
||||
broken.enemies = [missing_weapon, non_weapon]
|
||||
var packs: Array[SimulationContentPack] = [broken]
|
||||
var known_handlers: Array[StringName] = [&"known_handler"]
|
||||
var catalog := ContentCatalog.new()
|
||||
var errors := catalog.rebuild(packs, known_handlers)
|
||||
for expected in [
|
||||
"unknown required pack 'missing_pack'",
|
||||
"unknown required handler 'missing_handler'",
|
||||
"category 'unknown_category' is invalid",
|
||||
"unknown profession 'unknown_profession'",
|
||||
"unknown resource action 'unknown_resource_action'",
|
||||
"unknown completion cost item 'unknown_cost_item'",
|
||||
"non-resource completion cost item 'test_weapon'",
|
||||
"unknown weapon 'unknown_weapon'",
|
||||
"non-weapon item 'ordinary_resource'",
|
||||
]:
|
||||
_check(_has_error(errors, expected), "Catalog should report %s: %s" % [expected, errors])
|
||||
_check(not catalog.is_valid(), "Unknown references should invalidate the complete catalog")
|
||||
|
||||
|
||||
func _pack(pack_id: StringName, display_name: String) -> SimulationContentPack:
|
||||
var pack := SimulationContentPack.new()
|
||||
pack.pack_id = pack_id
|
||||
pack.display_name = display_name
|
||||
return pack
|
||||
|
||||
|
||||
func _resource_item(item_id: StringName, display_name: String) -> ItemDefinition:
|
||||
var item := ItemDefinition.new()
|
||||
item.item_id = item_id
|
||||
item.display_name = display_name
|
||||
item.category = ItemDefinition.CATEGORY_RESOURCE
|
||||
return item
|
||||
|
||||
|
||||
func _weapon_item(item_id: StringName, display_name: String) -> ItemDefinition:
|
||||
var item := ItemDefinition.new()
|
||||
item.item_id = item_id
|
||||
item.display_name = display_name
|
||||
item.category = ItemDefinition.CATEGORY_WEAPON
|
||||
item.equip_slot = SimulationIds.EQUIP_SLOT_HAND
|
||||
item.damage = 1.0
|
||||
item.reach = 1.0
|
||||
item.attack_cooldown = 1.0
|
||||
return item
|
||||
|
||||
|
||||
func _action(action_id: StringName) -> ActionDefinition:
|
||||
var action := ActionDefinition.new()
|
||||
action.action_id = action_id
|
||||
action.display_name = String(action_id).capitalize()
|
||||
action.target_type = SimulationIds.TARGET_ACTIVITY
|
||||
return action
|
||||
|
||||
|
||||
func _enemy(enemy_id: StringName, weapon_id: StringName) -> EnemyDefinition:
|
||||
var enemy := EnemyDefinition.new()
|
||||
enemy.enemy_id = enemy_id
|
||||
enemy.display_name = String(enemy_id).capitalize()
|
||||
enemy.kind = SimulationIds.COMBATANT_KIND_RAIDER
|
||||
enemy.weapon_id = weapon_id
|
||||
return enemy
|
||||
|
||||
|
||||
func _pack_ids(packs: Array[SimulationContentPack]) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for pack in packs:
|
||||
ids.append(pack.pack_id)
|
||||
return ids
|
||||
|
||||
|
||||
func _action_ids(actions: Array[ActionDefinition]) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for action in actions:
|
||||
ids.append(action.action_id)
|
||||
return ids
|
||||
|
||||
|
||||
func _profession_ids(professions: Array[ProfessionDefinition]) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for profession in professions:
|
||||
ids.append(profession.profession_id)
|
||||
return ids
|
||||
|
||||
|
||||
func _item_ids(items: Array[ItemDefinition]) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for item in items:
|
||||
ids.append(item.item_id)
|
||||
return ids
|
||||
|
||||
|
||||
func _enemy_ids(enemies: Array[EnemyDefinition]) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for enemy in enemies:
|
||||
ids.append(enemy.enemy_id)
|
||||
return ids
|
||||
|
||||
|
||||
func _has_error(errors: Array[String], fragment: String) -> bool:
|
||||
for error in errors:
|
||||
if fragment in error:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Content catalog passed")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvwpt4l3vp4ir
|
||||
@@ -0,0 +1,172 @@
|
||||
extends GutTest
|
||||
|
||||
|
||||
func test_value_objects_round_trip_and_reject_invalid_data() -> void:
|
||||
var entity_ref := WorldEntityRef.create(&"npc", &"7")
|
||||
var address := SpatialAddress.create(&"jajce", &"village_square", Vector3(2.0, 3.0, 4.0))
|
||||
|
||||
assert_not_null(entity_ref)
|
||||
assert_eq(
|
||||
WorldEntityRef.from_dictionary(entity_ref.to_dictionary()).to_dictionary(),
|
||||
entity_ref.to_dictionary()
|
||||
)
|
||||
assert_not_null(address)
|
||||
assert_eq(
|
||||
SpatialAddress.from_dictionary(address.to_dictionary()).to_dictionary(),
|
||||
address.to_dictionary()
|
||||
)
|
||||
assert_null(WorldEntityRef.create(&"npc", &""))
|
||||
assert_null(
|
||||
(
|
||||
SpatialAddress
|
||||
. from_dictionary(
|
||||
{
|
||||
"schema_version": SpatialAddress.SCHEMA_VERSION,
|
||||
"world_id": "jajce",
|
||||
"location_id": "square",
|
||||
"position": [0.0, 1.0],
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func test_record_serialization_is_deterministic_and_defensive() -> void:
|
||||
var actor := WorldEntityRef.create(&"npc", &"7")
|
||||
var resource := WorldEntityRef.create(&"resource", &"berry_patch_a")
|
||||
var participants := {
|
||||
&"source": resource,
|
||||
&"actor": actor,
|
||||
}
|
||||
var payload := {
|
||||
&"zeta": 2,
|
||||
&"alpha": &"food",
|
||||
&"nested": {&"beta": 2, &"alpha": 1},
|
||||
}
|
||||
var event := WorldEventRecord.create(
|
||||
42,
|
||||
&"resource_extracted",
|
||||
12,
|
||||
participants,
|
||||
SpatialAddress.create(&"jajce", &"forest_edge", Vector3(4.0, 0.0, 8.0)),
|
||||
payload
|
||||
)
|
||||
|
||||
participants.clear()
|
||||
payload[&"zeta"] = 99
|
||||
var serialized := event.to_dictionary()
|
||||
var restored := WorldEventRecord.from_dictionary(serialized)
|
||||
assert_not_null(restored)
|
||||
assert_eq(restored.to_dictionary(), serialized)
|
||||
assert_eq(JSON.stringify(restored.to_dictionary()), JSON.stringify(serialized))
|
||||
assert_eq(restored.get_participant_roles(), [&"actor", &"source"])
|
||||
assert_eq(restored.get_payload()["zeta"], 2)
|
||||
assert_eq(restored.get_payload()["alpha"], "food")
|
||||
serialized["payload"]["zeta"] = 100
|
||||
assert_eq(restored.get_payload()["zeta"], 2)
|
||||
|
||||
|
||||
func test_store_indexes_id_type_participant_location_and_tick() -> void:
|
||||
var store := WorldEventStore.new()
|
||||
var actor_a := WorldEntityRef.create(&"npc", &"7")
|
||||
var actor_b := WorldEntityRef.create(&"npc", &"8")
|
||||
var pantry := WorldEntityRef.create(&"storage", &"village_pantry")
|
||||
var square := SpatialAddress.create(&"jajce", &"village_square", Vector3.ZERO)
|
||||
var forest := SpatialAddress.create(&"jajce", &"forest_edge", Vector3(12.0, 0.0, 4.0))
|
||||
var later_event := WorldEventRecord.create(
|
||||
20,
|
||||
&"storage_deposited",
|
||||
9,
|
||||
{&"actor": actor_a, &"destination": pantry},
|
||||
square,
|
||||
{"item_id": "food", "amount": 2.0}
|
||||
)
|
||||
var earlier_event := WorldEventRecord.create(
|
||||
7, &"resource_extracted", 4, {&"actor": actor_a}, forest, {"item_id": "food", "amount": 2.0}
|
||||
)
|
||||
var same_tick_event := WorldEventRecord.create(
|
||||
9,
|
||||
&"storage_deposited",
|
||||
9,
|
||||
{&"actor": actor_b, &"source": pantry},
|
||||
square,
|
||||
{"item_id": "food", "amount": 1.0}
|
||||
)
|
||||
|
||||
assert_true(store.append(later_event))
|
||||
assert_true(store.append(earlier_event))
|
||||
assert_true(store.append(same_tick_event))
|
||||
assert_false(store.append(later_event))
|
||||
assert_same(store.get_by_id(20), store.get_by_id(20))
|
||||
assert_eq(_event_ids(store.get_all()), [7, 9, 20])
|
||||
assert_eq(_event_ids(store.get_for_type(&"storage_deposited")), [9, 20])
|
||||
assert_eq(_event_ids(store.get_for_participant(actor_a)), [7, 20])
|
||||
assert_eq(_event_ids(store.get_for_participant(pantry)), [9, 20])
|
||||
assert_eq(_event_ids(store.get_for_location(square)), [9, 20])
|
||||
assert_eq(_event_ids(store.get_for_tick(9)), [9, 20])
|
||||
assert_eq(_event_ids(store.get_between_ticks(4, 9)), [7, 9, 20])
|
||||
assert_true(store.get_for_type(&"unknown").is_empty())
|
||||
|
||||
|
||||
func test_store_round_trip_rebuilds_all_indexes_transactionally() -> void:
|
||||
var original := WorldEventStore.new()
|
||||
var actor := WorldEntityRef.create(&"npc", &"3")
|
||||
var address := SpatialAddress.create(&"jajce", &"pasture", Vector3(1.0, 0.0, 2.0))
|
||||
assert_true(
|
||||
original.append(
|
||||
WorldEventRecord.create(
|
||||
5, &"animal_fed", 14, {&"actor": actor}, address, {"amount": 1.0}
|
||||
)
|
||||
)
|
||||
)
|
||||
var serialized := original.to_dictionary()
|
||||
var restored := WorldEventStore.from_dictionary(serialized)
|
||||
|
||||
assert_not_null(restored)
|
||||
assert_eq(restored.to_dictionary(), serialized)
|
||||
assert_eq(_event_ids(restored.get_for_type(&"animal_fed")), [5])
|
||||
assert_eq(_event_ids(restored.get_for_participant(actor)), [5])
|
||||
assert_eq(_event_ids(restored.get_for_location(address)), [5])
|
||||
assert_eq(_event_ids(restored.get_for_tick(14)), [5])
|
||||
|
||||
var invalid_restore := {
|
||||
"schema_version": WorldEventStore.SCHEMA_VERSION,
|
||||
"events": [serialized["events"][0], serialized["events"][0]],
|
||||
}
|
||||
assert_false(restored.restore_from_dictionary(invalid_restore))
|
||||
assert_eq(restored.to_dictionary(), serialized)
|
||||
|
||||
|
||||
func test_economic_event_compatibility_conversion_preserves_contract() -> void:
|
||||
var economic := EconomicEventRecord.create(
|
||||
12,
|
||||
&"storage_deposited",
|
||||
30,
|
||||
4,
|
||||
&"npc_inventory_4",
|
||||
&"village_pantry",
|
||||
&"food",
|
||||
2.5,
|
||||
Vector3(3.0, 0.0, 7.0)
|
||||
)
|
||||
economic.data["custom_fact"] = {"reason": "harvest"}
|
||||
var world_event := WorldEventRecord.from_economic_event(economic, &"jajce", &"village_pantry")
|
||||
var round_tripped := world_event.to_economic_event()
|
||||
|
||||
assert_not_null(world_event)
|
||||
assert_eq(world_event.get_event_id(), 12)
|
||||
assert_eq(world_event.get_participant(&"actor").get_entity_id(), &"4")
|
||||
assert_eq(world_event.get_participant(&"source").get_entity_id(), &"npc_inventory_4")
|
||||
assert_eq(world_event.get_participant(&"destination").get_entity_id(), &"village_pantry")
|
||||
assert_eq(round_tripped.to_dictionary(), economic.to_dictionary())
|
||||
|
||||
var store := WorldEventStore.new()
|
||||
assert_not_null(store.append_economic(economic, &"jajce", &"village_pantry"))
|
||||
assert_eq(store.get_by_id(12).to_economic_event().to_dictionary(), economic.to_dictionary())
|
||||
|
||||
|
||||
func _event_ids(events: Array[WorldEventRecord]) -> Array[int]:
|
||||
var ids: Array[int] = []
|
||||
for event in events:
|
||||
ids.append(event.get_event_id())
|
||||
return ids
|
||||
@@ -0,0 +1 @@
|
||||
uid://1tp4ttukj4bv
|
||||
@@ -0,0 +1,256 @@
|
||||
extends SceneTree
|
||||
|
||||
const CapabilityScript := preload("res://world/targets/WorldTargetCapability.gd")
|
||||
const DescriptorScript := preload("res://world/targets/WorldTargetDescriptor.gd")
|
||||
const RegistryScript := preload("res://world/targets/WorldTargetRegistry.gd")
|
||||
const ActionCommandScript := preload("res://simulation/commands/ActionCommand.gd")
|
||||
const ActionResultScript := preload("res://simulation/commands/ActionResult.gd")
|
||||
const ActionOfferScript := preload("res://simulation/commands/ActionOffer.gd")
|
||||
const InteractionServiceScript := preload("res://simulation/commands/InteractionService.gd")
|
||||
const ActionCommandServiceScript := preload("res://simulation/commands/ActionCommandService.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_test_canonical_lookup_and_cross_kind_uniqueness()
|
||||
_test_deterministic_iteration_and_capability_updates()
|
||||
_test_transient_handle_lifecycle()
|
||||
_test_two_world_isolation()
|
||||
_test_descriptors_and_commands_are_non_authoritative_values()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_canonical_lookup_and_cross_kind_uniqueness() -> void:
|
||||
var registry := RegistryScript.new(&"jajce")
|
||||
var descriptor: WorldTargetDescriptor = _descriptor(
|
||||
&"shared_target", &"resource", [&"gather_food"], Vector3(2.0, 0.0, 3.0)
|
||||
)
|
||||
var handle = registry.register_target(descriptor)
|
||||
_check(handle != null, "A valid target should register")
|
||||
_check(registry.size() == 1, "The registry should contain one canonical target")
|
||||
_check(
|
||||
registry.get_descriptor(&"shared_target").get_target_kind() == &"resource",
|
||||
"Canonical lookup should return the registered descriptor"
|
||||
)
|
||||
var conflicting: WorldTargetDescriptor = _descriptor(
|
||||
&"shared_target", &"storage", [&"deposit_food"]
|
||||
)
|
||||
_check(
|
||||
registry.register_target(conflicting) == null,
|
||||
"One target ID must not register under a second kind in the same context"
|
||||
)
|
||||
_check(
|
||||
registry.last_error.contains("already registered as kind 'resource'"),
|
||||
"A cross-kind collision should expose a useful non-diagnostic error"
|
||||
)
|
||||
_check(registry.size() == 1, "A rejected collision must not replace the canonical entry")
|
||||
|
||||
|
||||
func _test_deterministic_iteration_and_capability_updates() -> void:
|
||||
var registry := RegistryScript.new(&"ordered_world")
|
||||
registry.register_target(_descriptor(&"target_z", &"resource", [&"gather_food"]))
|
||||
var middle_handle = registry.register_target(
|
||||
_descriptor(&"target_m", &"activity", [&"rest", &"socialize"])
|
||||
)
|
||||
registry.register_target(_descriptor(&"target_a", &"resource", [&"gather_food"]))
|
||||
|
||||
_check(
|
||||
registry.get_target_ids() == [&"target_a", &"target_m", &"target_z"],
|
||||
"Iteration should be stable-ID ordered regardless of registration order"
|
||||
)
|
||||
_check(
|
||||
registry.get_target_ids(&"resource") == [&"target_a", &"target_z"],
|
||||
"Kind-filtered iteration should remain deterministic"
|
||||
)
|
||||
_check(
|
||||
registry.get_target_ids(&"", &"gather_food") == [&"target_a", &"target_z"],
|
||||
"Capability-filtered iteration should remain deterministic"
|
||||
)
|
||||
var updated: WorldTargetDescriptor = _descriptor(
|
||||
&"target_m", &"activity", [&"trade"], Vector3(8.0, 0.0, -2.0)
|
||||
)
|
||||
_check(registry.update_target(middle_handle, updated), "A live handle should update facts")
|
||||
_check(
|
||||
registry.get_target_ids(&"", &"socialize").is_empty(),
|
||||
"Updating capabilities should remove old capability-index membership"
|
||||
)
|
||||
_check(
|
||||
registry.get_target_ids(&"", &"trade") == [&"target_m"],
|
||||
"Updating capabilities should add new capability-index membership"
|
||||
)
|
||||
_check(
|
||||
registry.resolve_handle(middle_handle).get_local_position() == Vector3(8.0, 0.0, -2.0),
|
||||
"A target update should preserve handle identity while replacing transient facts"
|
||||
)
|
||||
|
||||
|
||||
func _test_transient_handle_lifecycle() -> void:
|
||||
var registry := RegistryScript.new(&"lifecycle_world")
|
||||
var first_provider := Node.new()
|
||||
var first_handle = registry.register_target(
|
||||
_descriptor(&"workbench", &"activity", [&"craft"]), first_provider
|
||||
)
|
||||
_check(
|
||||
registry.is_handle_valid(first_handle, true),
|
||||
"A newly registered provider handle should resolve"
|
||||
)
|
||||
_check(
|
||||
registry.get_provider(first_handle) == first_provider,
|
||||
"A handle should resolve its provider"
|
||||
)
|
||||
_check(
|
||||
registry.unregister_target(first_handle), "The current handle should unregister its target"
|
||||
)
|
||||
_check(
|
||||
not registry.is_handle_valid(first_handle), "Unregistration should invalidate the handle"
|
||||
)
|
||||
|
||||
var second_provider := Node.new()
|
||||
var second_handle = registry.register_target(
|
||||
_descriptor(&"workbench", &"activity", [&"craft"]), second_provider
|
||||
)
|
||||
_check(
|
||||
second_handle.get_generation() > first_handle.get_generation(),
|
||||
"Re-registration should advance the transient generation"
|
||||
)
|
||||
_check(
|
||||
not registry.unregister_target(first_handle),
|
||||
"A stale provider must not unregister a newer canonical target"
|
||||
)
|
||||
_check(
|
||||
registry.is_handle_valid(second_handle, true),
|
||||
"Rejecting a stale handle should leave the newer target intact"
|
||||
)
|
||||
second_provider.free()
|
||||
_check(
|
||||
registry.prune_invalid_providers() == [&"workbench"],
|
||||
"Dead weak providers should be pruned deterministically"
|
||||
)
|
||||
_check(not registry.is_handle_valid(second_handle), "Pruning should invalidate its handle")
|
||||
first_provider.free()
|
||||
|
||||
|
||||
func _test_two_world_isolation() -> void:
|
||||
var first_world := RegistryScript.new(&"world_one")
|
||||
var second_world := RegistryScript.new(&"world_two")
|
||||
var first_handle = first_world.register_target(
|
||||
_descriptor(&"village_pantry", &"storage", [&"withdraw_food"])
|
||||
)
|
||||
var second_handle = second_world.register_target(
|
||||
_descriptor(&"village_pantry", &"landmark", [&"inspect"])
|
||||
)
|
||||
|
||||
_check(
|
||||
first_handle != null and second_handle != null, "Two contexts may reuse a local target ID"
|
||||
)
|
||||
_check(
|
||||
first_world.resolve_handle(second_handle) == null,
|
||||
"A handle from another registry must never resolve, even with the same target ID"
|
||||
)
|
||||
_check(
|
||||
second_world.resolve_handle(first_handle) == null,
|
||||
"Registry ownership should isolate handles in both directions"
|
||||
)
|
||||
_check(
|
||||
first_world.unregister_target(first_handle),
|
||||
"The first world should unregister independently"
|
||||
)
|
||||
_check(
|
||||
second_world.is_handle_valid(second_handle),
|
||||
"Unloading one context must not affect a second context's canonical target"
|
||||
)
|
||||
_check(
|
||||
second_world.get_descriptor(&"village_pantry").get_target_kind() == &"landmark",
|
||||
"Each context should retain its own target facts"
|
||||
)
|
||||
|
||||
|
||||
func _test_descriptors_and_commands_are_non_authoritative_values() -> void:
|
||||
var attributes := {"amounts": [1, 2]}
|
||||
var metadata := {"tags": ["village"]}
|
||||
var capability := CapabilityScript.new(&"inspect", attributes)
|
||||
var descriptor := DescriptorScript.new(
|
||||
&"notice_board", &"activity", [capability], Vector3.ZERO, "Notice board", metadata
|
||||
)
|
||||
attributes["amounts"][0] = 99
|
||||
metadata["tags"].append("mutated")
|
||||
var registry := RegistryScript.new(&"command_world")
|
||||
var handle = registry.register_target(descriptor)
|
||||
var stored := registry.get_descriptor(&"notice_board")
|
||||
_check(
|
||||
stored.get_capability(&"inspect").get_attribute(&"amounts") == [1, 2],
|
||||
"Capability attributes should be isolated from caller mutation"
|
||||
)
|
||||
_check(
|
||||
stored.get_metadata_value(&"tags") == ["village"],
|
||||
"Target metadata should be isolated from caller mutation"
|
||||
)
|
||||
|
||||
var offer_parameters := {"page": 1}
|
||||
var offer := ActionOfferScript.new(
|
||||
&"inspect_board", &"inspect", handle, "Read notices", true, "", offer_parameters, 2.0
|
||||
)
|
||||
offer_parameters["page"] = 9
|
||||
_check(offer.is_valid(), "A well-formed advisory action offer should be valid")
|
||||
_check(offer.get_parameters()["page"] == 1, "An offer should copy its parameter value")
|
||||
var command_parameters := {"page": 1}
|
||||
var command := ActionCommandScript.new(
|
||||
&"command_1", &"person_7", offer.get_action_id(), offer.get_target(), command_parameters
|
||||
)
|
||||
command_parameters["page"] = 5
|
||||
_check(command.is_valid(), "A command should carry a well-formed transient target intent")
|
||||
_check(command.get_parameters()["page"] == 1, "A command should copy its parameter value")
|
||||
_check(registry.unregister_target(handle), "The offered target should be removable")
|
||||
_check(
|
||||
command.is_valid() and not registry.is_handle_valid(command.get_target()),
|
||||
"Command shape validity must not make a stale target authoritative"
|
||||
)
|
||||
|
||||
var accepted := ActionResultScript.accepted(&"command_1", {"queued": true})
|
||||
var rejected := ActionResultScript.rejected(&"command_2", &"stale_target", "Try again")
|
||||
_check(accepted.is_valid() and accepted.is_accepted(), "Accepted results should be explicit")
|
||||
_check(
|
||||
rejected.is_valid() and rejected.is_terminal() and not rejected.is_accepted(),
|
||||
"Rejected results should carry a terminal, non-success status"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
InteractionServiceScript
|
||||
. new()
|
||||
. get_action_offers(&"person_7", command.get_target())
|
||||
. is_empty()
|
||||
),
|
||||
"The manager-independent interaction interface should have no implicit offers"
|
||||
)
|
||||
_check(
|
||||
ActionCommandServiceScript.new().submit_command(command) == null,
|
||||
"The manager-independent command interface should have no implicit authority"
|
||||
)
|
||||
|
||||
|
||||
func _descriptor(
|
||||
target_id: StringName,
|
||||
target_kind: StringName,
|
||||
capability_ids: Array[StringName],
|
||||
position: Vector3 = Vector3.ZERO
|
||||
) -> WorldTargetDescriptor:
|
||||
var capabilities: Array = []
|
||||
for capability_id in capability_ids:
|
||||
capabilities.append(CapabilityScript.new(capability_id))
|
||||
return DescriptorScript.new(target_id, target_kind, capabilities, position)
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] World target registry passed: scoped canonical IDs, handles, and commands")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3linx25d7web
|
||||
Reference in New Issue
Block a user