feat: add emergent world foundations

This commit is contained in:
Rijad Zuzo
2026-08-12 20:06:51 +02:00
parent fa04137a04
commit ea00f60165
44 changed files with 2665 additions and 7 deletions
+53
View File
@@ -0,0 +1,53 @@
class_name ActionCommand
extends RefCounted
var _command_id: StringName
var _actor_id: StringName
var _action_id: StringName
var _target: WorldTargetHandle
var _parameters: Dictionary
func _init(
command_id: StringName = &"",
actor_id: StringName = &"",
action_id: StringName = &"",
target: WorldTargetHandle = null,
parameters: Dictionary = {}
) -> void:
_command_id = command_id
_actor_id = actor_id
_action_id = action_id
_target = target
_parameters = parameters.duplicate(true)
func is_valid() -> bool:
return (
not _command_id.is_empty()
and not _actor_id.is_empty()
and not _action_id.is_empty()
and _target != null
and _target.is_well_formed()
and not WorldTargetCapability._contains_object(_parameters)
)
func get_command_id() -> StringName:
return _command_id
func get_actor_id() -> StringName:
return _actor_id
func get_action_id() -> StringName:
return _action_id
func get_target() -> WorldTargetHandle:
return _target
func get_parameters() -> Dictionary:
return _parameters.duplicate(true)
+1
View File
@@ -0,0 +1 @@
uid://doxwel0w8eqx5
@@ -0,0 +1,10 @@
class_name ActionCommandService
extends RefCounted
signal command_resolved(result: ActionResult)
# Implementations must resolve the transient target handle and revalidate every
# precondition. An earlier ActionOffer never grants authority by itself.
func submit_command(_command: ActionCommand) -> ActionResult:
return null
@@ -0,0 +1 @@
uid://dfbj1brsbira5
+74
View File
@@ -0,0 +1,74 @@
class_name ActionOffer
extends RefCounted
var _offer_id: StringName
var _action_id: StringName
var _target: WorldTargetHandle
var _display_name: String
var _enabled: bool
var _rejection_reason: String
var _parameters: Dictionary
var _priority: float
func _init(
offer_id: StringName = &"",
action_id: StringName = &"",
target: WorldTargetHandle = null,
display_name: String = "",
enabled: bool = true,
rejection_reason: String = "",
parameters: Dictionary = {},
priority: float = 0.0
) -> void:
_offer_id = offer_id
_action_id = action_id
_target = target
_display_name = display_name
_enabled = enabled
_rejection_reason = rejection_reason
_parameters = parameters.duplicate(true)
_priority = priority
func is_valid() -> bool:
return (
not _offer_id.is_empty()
and not _action_id.is_empty()
and _target != null
and _target.is_well_formed()
and is_finite(_priority)
and not WorldTargetCapability._contains_object(_parameters)
)
func get_offer_id() -> StringName:
return _offer_id
func get_action_id() -> StringName:
return _action_id
func get_target() -> WorldTargetHandle:
return _target
func get_display_name() -> String:
return _display_name
func is_enabled() -> bool:
return _enabled
func get_rejection_reason() -> String:
return _rejection_reason
func get_parameters() -> Dictionary:
return _parameters.duplicate(true)
func get_priority() -> float:
return _priority
+1
View File
@@ -0,0 +1 @@
uid://c34gchiomlnpy
+88
View File
@@ -0,0 +1,88 @@
class_name ActionResult
extends RefCounted
const STATUS_ACCEPTED := &"accepted"
const STATUS_REJECTED := &"rejected"
const STATUS_COMPLETED := &"completed"
const STATUS_FAILED := &"failed"
const VALID_STATUSES := [STATUS_ACCEPTED, STATUS_REJECTED, STATUS_COMPLETED, STATUS_FAILED]
var _command_id: StringName
var _status: StringName
var _reason_code: StringName
var _message: String
var _payload: Dictionary
func _init(
command_id: StringName = &"",
status: StringName = &"",
reason_code: StringName = &"",
message: String = "",
payload: Dictionary = {}
) -> void:
_command_id = command_id
_status = status
_reason_code = reason_code
_message = message
_payload = payload.duplicate(true)
func is_valid() -> bool:
return (
not _command_id.is_empty()
and _status in VALID_STATUSES
and not WorldTargetCapability._contains_object(_payload)
)
func is_accepted() -> bool:
return _status in [STATUS_ACCEPTED, STATUS_COMPLETED]
func is_terminal() -> bool:
return _status in [STATUS_REJECTED, STATUS_COMPLETED, STATUS_FAILED]
func did_succeed() -> bool:
return _status == STATUS_COMPLETED
func get_command_id() -> StringName:
return _command_id
func get_status() -> StringName:
return _status
func get_reason_code() -> StringName:
return _reason_code
func get_message() -> String:
return _message
func get_payload() -> Dictionary:
return _payload.duplicate(true)
static func accepted(command_id: StringName, payload: Dictionary = {}) -> ActionResult:
return ActionResult.new(command_id, STATUS_ACCEPTED, &"", "", payload)
static func rejected(
command_id: StringName, reason_code: StringName, message: String = ""
) -> ActionResult:
return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message)
static func completed(command_id: StringName, payload: Dictionary = {}) -> ActionResult:
return ActionResult.new(command_id, STATUS_COMPLETED, &"", "", payload)
static func failed(
command_id: StringName, reason_code: StringName, message: String = ""
) -> ActionResult:
return ActionResult.new(command_id, STATUS_FAILED, reason_code, message)
+1
View File
@@ -0,0 +1 @@
uid://bs85tiv8cs3gi
@@ -0,0 +1,8 @@
class_name InteractionService
extends RefCounted
# Offers are advisory presentation values. Implementations must not mutate
# authoritative state while answering this query.
func get_action_offers(_actor_id: StringName, _target: WorldTargetHandle) -> Array[ActionOffer]:
return []
@@ -0,0 +1 @@
uid://bgd28t7y4x3mf
+340
View File
@@ -0,0 +1,340 @@
class_name ContentCatalog
extends RefCounted
const CORE_PACK_PATH := "res://simulation/definitions/packs/core.tres"
var _valid := false
var _errors: Array[String] = []
var _packs: Array[SimulationContentPack] = []
var _actions: Array[ActionDefinition] = []
var _professions: Array[ProfessionDefinition] = []
var _items: Array[ItemDefinition] = []
var _enemies: Array[EnemyDefinition] = []
var _packs_by_id: Dictionary = {}
var _actions_by_id: Dictionary = {}
var _professions_by_id: Dictionary = {}
var _items_by_id: Dictionary = {}
var _enemies_by_id: Dictionary = {}
static func create_core(supported_handler_ids: Array[StringName] = []) -> ContentCatalog:
var catalog := ContentCatalog.new()
var pack := load(CORE_PACK_PATH) as SimulationContentPack
if pack == null:
catalog._errors = ["Core content pack failed to load from '%s'" % CORE_PACK_PATH]
return catalog
var packs: Array[SimulationContentPack] = [pack]
catalog.rebuild(packs, supported_handler_ids)
return catalog
func rebuild(
content_packs: Array[SimulationContentPack], supported_handler_ids: Array[StringName] = []
) -> Array[String]:
_clear_published_content()
var errors: Array[String] = []
var pack_list := content_packs.duplicate()
pack_list.sort_custom(_sort_packs)
var supported_handlers := {}
for handler_id in supported_handler_ids:
if handler_id.is_empty():
errors.append("Supported handler ID is empty")
elif supported_handlers.has(handler_id):
errors.append("Duplicate supported handler '%s'" % handler_id)
else:
supported_handlers[handler_id] = true
var packs_by_id := {}
var actions_by_id := {}
var professions_by_id := {}
var items_by_id := {}
var enemies_by_id := {}
var actions: Array[ActionDefinition] = []
var professions: Array[ProfessionDefinition] = []
var items: Array[ItemDefinition] = []
var enemies: Array[EnemyDefinition] = []
for pack in pack_list:
if pack == null:
errors.append("Content pack list contains a null pack")
continue
for error in pack.validate():
errors.append("Pack '%s': %s" % [pack.pack_id, error])
if packs_by_id.has(pack.pack_id):
errors.append("Duplicate pack_id '%s'" % pack.pack_id)
else:
packs_by_id[pack.pack_id] = pack
_collect_actions(pack, actions, actions_by_id, errors)
_collect_professions(pack, professions, professions_by_id, errors)
_collect_items(pack, items, items_by_id, errors)
_collect_enemies(pack, enemies, enemies_by_id, errors)
for pack in pack_list:
if pack == null:
continue
for required_pack_id in pack.required_pack_ids:
if not required_pack_id.is_empty() and not packs_by_id.has(required_pack_id):
errors.append(
(
"Pack '%s' references unknown required pack '%s'"
% [pack.pack_id, required_pack_id]
)
)
for required_handler_id in pack.required_handler_ids:
if (
not required_handler_id.is_empty()
and not supported_handlers.has(required_handler_id)
):
errors.append(
(
"Pack '%s' references unknown required handler '%s'"
% [pack.pack_id, required_handler_id]
)
)
_validate_action_references(actions, actions_by_id, professions_by_id, items_by_id, errors)
_validate_enemy_references(enemies, items_by_id, errors)
errors.sort()
_errors = errors
if not errors.is_empty():
return get_errors()
actions.sort_custom(_sort_actions)
professions.sort_custom(_sort_professions)
items.sort_custom(_sort_items)
enemies.sort_custom(_sort_enemies)
_packs = pack_list
_actions = actions
_professions = professions
_items = items
_enemies = enemies
_packs_by_id = packs_by_id
_actions_by_id = actions_by_id
_professions_by_id = professions_by_id
_items_by_id = items_by_id
_enemies_by_id = enemies_by_id
_valid = true
return []
func is_valid() -> bool:
return _valid
func get_errors() -> Array[String]:
return _errors.duplicate()
func get_packs() -> Array[SimulationContentPack]:
return _packs.duplicate()
func get_actions() -> Array[ActionDefinition]:
return _actions.duplicate()
func get_professions() -> Array[ProfessionDefinition]:
return _professions.duplicate()
func get_items() -> Array[ItemDefinition]:
return _items.duplicate()
func get_enemies() -> Array[EnemyDefinition]:
return _enemies.duplicate()
func get_pack(pack_id: StringName) -> SimulationContentPack:
return _packs_by_id.get(pack_id) as SimulationContentPack
func get_action(action_id: StringName) -> ActionDefinition:
return _actions_by_id.get(action_id) as ActionDefinition
func get_profession(profession_id: StringName) -> ProfessionDefinition:
return _professions_by_id.get(profession_id) as ProfessionDefinition
func get_item(item_id: StringName) -> ItemDefinition:
return _items_by_id.get(item_id) as ItemDefinition
func get_enemy(enemy_id: StringName) -> EnemyDefinition:
return _enemies_by_id.get(enemy_id) as EnemyDefinition
func _clear_published_content() -> void:
_valid = false
_errors.clear()
_packs.clear()
_actions.clear()
_professions.clear()
_items.clear()
_enemies.clear()
_packs_by_id.clear()
_actions_by_id.clear()
_professions_by_id.clear()
_items_by_id.clear()
_enemies_by_id.clear()
static func _collect_actions(
pack: SimulationContentPack,
definitions: Array[ActionDefinition],
definitions_by_id: Dictionary,
errors: Array[String]
) -> void:
for definition in pack.actions:
if definition == null:
continue
if definitions_by_id.has(definition.action_id):
errors.append("Duplicate action_id '%s'" % definition.action_id)
continue
definitions_by_id[definition.action_id] = definition
definitions.append(definition)
static func _collect_professions(
pack: SimulationContentPack,
definitions: Array[ProfessionDefinition],
definitions_by_id: Dictionary,
errors: Array[String]
) -> void:
for definition in pack.professions:
if definition == null:
continue
if definitions_by_id.has(definition.profession_id):
errors.append("Duplicate profession_id '%s'" % definition.profession_id)
continue
definitions_by_id[definition.profession_id] = definition
definitions.append(definition)
static func _collect_items(
pack: SimulationContentPack,
definitions: Array[ItemDefinition],
definitions_by_id: Dictionary,
errors: Array[String]
) -> void:
for definition in pack.items:
if definition == null:
continue
if definitions_by_id.has(definition.item_id):
errors.append("Duplicate item_id '%s'" % definition.item_id)
continue
definitions_by_id[definition.item_id] = definition
definitions.append(definition)
static func _collect_enemies(
pack: SimulationContentPack,
definitions: Array[EnemyDefinition],
definitions_by_id: Dictionary,
errors: Array[String]
) -> void:
for definition in pack.enemies:
if definition == null:
continue
if definitions_by_id.has(definition.enemy_id):
errors.append("Duplicate enemy_id '%s'" % definition.enemy_id)
continue
definitions_by_id[definition.enemy_id] = definition
definitions.append(definition)
static func _validate_action_references(
actions: Array[ActionDefinition],
actions_by_id: Dictionary,
professions_by_id: Dictionary,
items_by_id: Dictionary,
errors: Array[String]
) -> void:
for definition in actions:
if (
not definition.preferred_profession_id.is_empty()
and not professions_by_id.has(definition.preferred_profession_id)
):
errors.append(
(
"Action '%s' references unknown profession '%s'"
% [definition.action_id, definition.preferred_profession_id]
)
)
if (
definition.target_type == SimulationIds.TARGET_RESOURCE
and not actions_by_id.has(definition.resource_action_id)
):
errors.append(
(
"Action '%s' references unknown resource action '%s'"
% [definition.action_id, definition.resource_action_id]
)
)
if (
not definition.completion_cost_resource_id.is_empty()
and not items_by_id.has(definition.completion_cost_resource_id)
):
errors.append(
(
"Action '%s' references unknown completion cost item '%s'"
% [definition.action_id, definition.completion_cost_resource_id]
)
)
elif not definition.completion_cost_resource_id.is_empty():
var cost_item := (
items_by_id.get(definition.completion_cost_resource_id) as ItemDefinition
)
if cost_item != null and not cost_item.is_resource():
errors.append(
(
"Action '%s' references non-resource completion cost item '%s'"
% [definition.action_id, definition.completion_cost_resource_id]
)
)
static func _validate_enemy_references(
enemies: Array[EnemyDefinition], items_by_id: Dictionary, errors: Array[String]
) -> void:
for definition in enemies:
var weapon := items_by_id.get(definition.weapon_id) as ItemDefinition
if weapon == null:
errors.append(
(
"Enemy '%s' references unknown weapon '%s'"
% [definition.enemy_id, definition.weapon_id]
)
)
elif not weapon.is_weapon():
errors.append(
(
"Enemy '%s' references non-weapon item '%s'"
% [definition.enemy_id, definition.weapon_id]
)
)
static func _sort_packs(first: SimulationContentPack, second: SimulationContentPack) -> bool:
if first == null:
return second != null
if second == null:
return false
return String(first.pack_id) < String(second.pack_id)
static func _sort_actions(first: ActionDefinition, second: ActionDefinition) -> bool:
return String(first.action_id) < String(second.action_id)
static func _sort_professions(first: ProfessionDefinition, second: ProfessionDefinition) -> bool:
return String(first.profession_id) < String(second.profession_id)
static func _sort_items(first: ItemDefinition, second: ItemDefinition) -> bool:
return String(first.item_id) < String(second.item_id)
static func _sort_enemies(first: EnemyDefinition, second: EnemyDefinition) -> bool:
return String(first.enemy_id) < String(second.enemy_id)
@@ -0,0 +1 @@
uid://csvigy66784n
+28 -7
View File
@@ -1,13 +1,17 @@
class_name ItemDefinition class_name ItemDefinition
extends Resource extends Resource
const CATEGORY_RESOURCE := &"resource"
const CATEGORY_WEAPON := SimulationIds.ITEM_CATEGORY_WEAPON
const VALID_CATEGORIES := [CATEGORY_RESOURCE, CATEGORY_WEAPON]
@export var item_id: StringName @export var item_id: StringName
@export var display_name: String @export var display_name: String
@export var category: StringName = SimulationIds.ITEM_CATEGORY_WEAPON @export var category: StringName = CATEGORY_RESOURCE
@export var equip_slot: StringName = SimulationIds.EQUIP_SLOT_HAND @export var equip_slot: StringName
@export_range(0.0, 1000.0, 0.1) var damage := 0.0 @export_range(0.0, 1000.0, 0.1) var damage := 0.0
@export_range(0.0, 50.0, 0.1) var reach := 1.5 @export_range(0.0, 50.0, 0.1) var reach := 0.0
@export_range(0.0, 5.0, 0.05) var attack_cooldown := 0.6 @export_range(0.0, 5.0, 0.05) var attack_cooldown := 0.0
func validate() -> Array[String]: func validate() -> Array[String]:
@@ -16,10 +20,27 @@ func validate() -> Array[String]:
errors.append("item_id is empty") errors.append("item_id is empty")
if display_name.is_empty(): if display_name.is_empty():
errors.append("display_name is empty for '%s'" % item_id) errors.append("display_name is empty for '%s'" % item_id)
if damage <= 0.0: if category not in VALID_CATEGORIES:
errors.append("damage must be positive for '%s'" % item_id) errors.append("category '%s' is invalid for '%s'" % [category, item_id])
elif category == CATEGORY_WEAPON:
if equip_slot != SimulationIds.EQUIP_SLOT_HAND:
errors.append(
"weapon '%s' must equip in '%s'" % [item_id, SimulationIds.EQUIP_SLOT_HAND]
)
if not is_finite(damage) or damage <= 0.0:
errors.append("damage must be positive for '%s'" % item_id)
if not is_finite(reach) or reach <= 0.0:
errors.append("reach must be positive for '%s'" % item_id)
if not is_finite(attack_cooldown) or attack_cooldown <= 0.0:
errors.append("attack_cooldown must be positive for '%s'" % item_id)
elif not equip_slot.is_empty():
errors.append("resource item '%s' cannot declare equip_slot '%s'" % [item_id, equip_slot])
return errors return errors
func is_weapon() -> bool: func is_weapon() -> bool:
return category == SimulationIds.ITEM_CATEGORY_WEAPON return category == CATEGORY_WEAPON
func is_resource() -> bool:
return category == CATEGORY_RESOURCE
@@ -0,0 +1,78 @@
class_name SimulationContentPack
extends Resource
@export var pack_id: StringName
@export var display_name: String
@export var required_pack_ids: Array[StringName] = []
@export var required_handler_ids: Array[StringName] = []
@export var actions: Array[ActionDefinition] = []
@export var professions: Array[ProfessionDefinition] = []
@export var items: Array[ItemDefinition] = []
@export var enemies: Array[EnemyDefinition] = []
func validate() -> Array[String]:
var errors: Array[String] = []
if pack_id.is_empty():
errors.append("pack_id is empty")
if display_name.is_empty():
errors.append("display_name is empty for pack '%s'" % pack_id)
_validate_dependency_ids(required_pack_ids, "required pack", errors)
_validate_dependency_ids(required_handler_ids, "required handler", errors)
if pack_id in required_pack_ids:
errors.append("pack '%s' cannot require itself" % pack_id)
_validate_actions(errors)
_validate_professions(errors)
_validate_items(errors)
_validate_enemies(errors)
return errors
func _validate_actions(errors: Array[String]) -> void:
for definition in actions:
if definition == null:
errors.append("actions contains a null definition")
continue
for error in definition.validate():
errors.append("ActionDefinition: " + error)
func _validate_professions(errors: Array[String]) -> void:
for definition in professions:
if definition == null:
errors.append("professions contains a null definition")
continue
for error in definition.validate():
errors.append("ProfessionDefinition: " + error)
func _validate_items(errors: Array[String]) -> void:
for definition in items:
if definition == null:
errors.append("items contains a null definition")
continue
for error in definition.validate():
errors.append("ItemDefinition: " + error)
func _validate_enemies(errors: Array[String]) -> void:
for definition in enemies:
if definition == null:
errors.append("enemies contains a null definition")
continue
for error in definition.validate():
errors.append("EnemyDefinition: " + error)
static func _validate_dependency_ids(
ids: Array[StringName], label: String, errors: Array[String]
) -> void:
var seen := {}
for dependency_id in ids:
if dependency_id.is_empty():
errors.append("%s ID is empty" % label)
continue
if seen.has(dependency_id):
errors.append("duplicate %s '%s'" % [label, dependency_id])
continue
seen[dependency_id] = true
@@ -0,0 +1 @@
uid://j65xpgmfakin
@@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/EnemyDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
enemy_id = &"enemy_raider"
display_name = "Raider"
kind = &"raider"
weapon_id = &"item_sword"
health = 60.0
move_speed = 3.5
body_color = Color(0.4, 0.13, 0.1, 1)
accent_color = Color(0.55, 0.55, 0.58, 1)
+15
View File
@@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/EnemyDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
enemy_id = &"enemy_wolf"
display_name = "Wolf"
kind = &"wolf"
weapon_id = &"item_claw"
health = 40.0
move_speed = 4.2
body_color = Color(0.32, 0.32, 0.34, 1)
accent_color = Color(0.95, 0.5, 0.12, 1)
visual_scale = Vector3(0.8, 0.8, 0.8)
+13
View File
@@ -0,0 +1,13 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
item_id = &"item_claw"
display_name = "Claw"
category = &"weapon"
equip_slot = &"hand"
damage = 10.0
reach = 1.2
attack_cooldown = 0.4
+9
View File
@@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
item_id = &"food"
display_name = "Food"
category = &"resource"
+13
View File
@@ -0,0 +1,13 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
item_id = &"item_sword"
display_name = "Sword"
category = &"weapon"
equip_slot = &"hand"
damage = 24.0
reach = 2.4
attack_cooldown = 0.55
+9
View File
@@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
item_id = &"wood"
display_name = "Wood"
category = &"resource"
+36
View File
@@ -0,0 +1,36 @@
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=27 format=3]
[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/deposit_food.tres" id="3_deposit_food"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/deposit_wood.tres" id="4_deposit_wood"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/eat.tres" id="5_eat"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/feed_animal.tres" id="6_feed_animal"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/gather_food.tres" id="7_gather_food"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/gather_wood.tres" id="8_gather_wood"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/patrol.tres" id="9_patrol"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/rest.tres" id="10_rest"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/sleep.tres" id="11_sleep"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/study.tres" id="12_study"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/wander.tres" id="13_wander"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/withdraw_food.tres" id="14_withdraw_food"]
[ext_resource type="Resource" path="res://simulation/definitions/professions/farmer.tres" id="15_farmer"]
[ext_resource type="Resource" path="res://simulation/definitions/professions/guard.tres" id="16_guard"]
[ext_resource type="Resource" path="res://simulation/definitions/professions/scholar.tres" id="17_scholar"]
[ext_resource type="Resource" path="res://simulation/definitions/professions/wanderer.tres" id="18_wanderer"]
[ext_resource type="Resource" path="res://simulation/definitions/professions/woodcutter.tres" id="19_woodcutter"]
[ext_resource type="Resource" path="res://simulation/definitions/items/claw.tres" id="20_claw"]
[ext_resource type="Resource" path="res://simulation/definitions/items/food.tres" id="21_food"]
[ext_resource type="Resource" path="res://simulation/definitions/items/sword.tres" id="22_sword"]
[ext_resource type="Resource" path="res://simulation/definitions/items/wood.tres" id="23_wood"]
[ext_resource type="Resource" path="res://simulation/definitions/enemies/raider.tres" id="24_raider"]
[ext_resource type="Resource" path="res://simulation/definitions/enemies/wolf.tres" id="25_wolf"]
[resource]
script = ExtResource("1_pack")
pack_id = &"core"
display_name = "The Steward Core"
actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")]
professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")]
items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood")]
enemies = [ExtResource("24_raider"), ExtResource("25_wolf")]
+204
View File
@@ -0,0 +1,204 @@
class_name WorldEventStore
extends RefCounted
signal event_recorded(event: WorldEventRecord)
const SCHEMA_VERSION := 1
var _events_by_id: Dictionary = {}
var _ordered_event_ids: Array[int] = []
var _event_type_index: Dictionary = {}
var _participant_index: Dictionary = {}
var _location_index: Dictionary = {}
var _tick_index: Dictionary = {}
var _ordered_ticks: Array[int] = []
static func from_dictionary(store_data: Dictionary) -> WorldEventStore:
var store := WorldEventStore.new()
return store if store.restore_from_dictionary(store_data) else null
func size() -> int:
return _events_by_id.size()
func is_empty() -> bool:
return _events_by_id.is_empty()
func has_event(event_id: int) -> bool:
return _events_by_id.has(event_id)
func append(event: WorldEventRecord) -> bool:
return _append(event, true)
func append_economic(
economic_event: EconomicEventRecord, world_id: StringName = &"", location_id: StringName = &""
) -> WorldEventRecord:
var world_event := WorldEventRecord.from_economic_event(economic_event, world_id, location_id)
if world_event == null or not append(world_event):
return null
return get_by_id(world_event.get_event_id())
func get_by_id(event_id: int) -> WorldEventRecord:
return _events_by_id.get(event_id) as WorldEventRecord
func get_by_ids(event_ids: Array[int]) -> Array[WorldEventRecord]:
var results: Array[WorldEventRecord] = []
for event_id in event_ids:
var event := get_by_id(event_id)
if event != null:
results.append(event)
return results
func get_all() -> Array[WorldEventRecord]:
return _records_for_ids(_ordered_event_ids)
func get_for_type(event_type: StringName) -> Array[WorldEventRecord]:
return _records_for_ids(_event_type_index.get(event_type, []))
func get_for_participant(participant: WorldEntityRef) -> Array[WorldEventRecord]:
if participant == null or not participant.is_valid():
return []
return _records_for_ids(_participant_index.get(participant.index_key(), []))
func get_for_location(location: SpatialAddress) -> Array[WorldEventRecord]:
if location == null or not location.is_valid():
return []
return _records_for_ids(_location_index.get(location.index_key(), []))
func get_for_tick(tick: int) -> Array[WorldEventRecord]:
return _records_for_ids(_tick_index.get(tick, []))
func get_between_ticks(first_tick: int, last_tick: int) -> Array[WorldEventRecord]:
var results: Array[WorldEventRecord] = []
if first_tick < 0 or last_tick < first_tick:
return results
for tick in _ordered_ticks:
if tick < first_tick:
continue
if tick > last_tick:
break
results.append_array(_records_for_ids(_tick_index[tick]))
return results
func restore(restored_events: Array[WorldEventRecord]) -> bool:
var candidate := WorldEventStore.new()
for event in restored_events:
if not candidate._append(event, false):
return false
_adopt_indexes(candidate)
return true
func restore_from_dictionary(store_data: Dictionary) -> bool:
if int(store_data.get("schema_version", -1)) != SCHEMA_VERSION:
return false
var saved_events = store_data.get("events")
if not saved_events is Array:
return false
var restored_events: Array[WorldEventRecord] = []
for saved_event in saved_events:
if not saved_event is Dictionary:
return false
var event := WorldEventRecord.from_dictionary(saved_event)
if event == null:
return false
restored_events.append(event)
return restore(restored_events)
func to_dictionary() -> Dictionary:
var serialized_events: Array[Dictionary] = []
for event in get_all():
serialized_events.append(event.to_dictionary())
return {
"schema_version": SCHEMA_VERSION,
"events": serialized_events,
}
func _append(event: WorldEventRecord, emit_signal: bool) -> bool:
if event == null or not event.is_valid() or _events_by_id.has(event.get_event_id()):
return false
var stored_event := WorldEventRecord.from_dictionary(event.to_dictionary())
if stored_event == null:
return false
var event_id := stored_event.get_event_id()
_events_by_id[event_id] = stored_event
_insert_event_id(_ordered_event_ids, event_id)
_index_event_id(_event_type_index, stored_event.get_event_type(), event_id)
for participant_key in stored_event.get_participant_index_keys():
_index_event_id(_participant_index, participant_key, event_id)
if stored_event.has_location():
_index_event_id(_location_index, stored_event.get_location().index_key(), event_id)
_index_event_id(_tick_index, stored_event.get_tick(), event_id)
_insert_tick(stored_event.get_tick())
if emit_signal:
event_recorded.emit(stored_event)
return true
func _index_event_id(index: Dictionary, key: Variant, event_id: int) -> void:
if not index.has(key):
index[key] = []
var indexed_ids: Array = index[key]
_insert_event_id(indexed_ids, event_id)
func _insert_event_id(event_ids: Array, event_id: int) -> void:
var low := 0
var high := event_ids.size()
while low < high:
var middle := (low + high) / 2
if _event_precedes(event_id, int(event_ids[middle])):
high = middle
else:
low = middle + 1
event_ids.insert(low, event_id)
func _event_precedes(left_id: int, right_id: int) -> bool:
var left := get_by_id(left_id)
var right := get_by_id(right_id)
if left.get_tick() != right.get_tick():
return left.get_tick() < right.get_tick()
return left_id < right_id
func _insert_tick(tick: int) -> void:
var insert_at := _ordered_ticks.bsearch(tick)
if insert_at < _ordered_ticks.size() and _ordered_ticks[insert_at] == tick:
return
_ordered_ticks.insert(insert_at, tick)
func _records_for_ids(event_ids: Array) -> Array[WorldEventRecord]:
var results: Array[WorldEventRecord] = []
for event_id in event_ids:
var event := get_by_id(int(event_id))
if event != null:
results.append(event)
return results
func _adopt_indexes(source: WorldEventStore) -> void:
_events_by_id = source._events_by_id
_ordered_event_ids = source._ordered_event_ids
_event_type_index = source._event_type_index
_participant_index = source._participant_index
_location_index = source._location_index
_tick_index = source._tick_index
_ordered_ticks = source._ordered_ticks
+1
View File
@@ -0,0 +1 @@
uid://dss5liteqydyd
+83
View File
@@ -0,0 +1,83 @@
class_name SpatialAddress
extends RefCounted
const SCHEMA_VERSION := 1
var _world_id: StringName
var _location_id: StringName
var _position: Vector3
func _init(
world_id: StringName = &"", location_id: StringName = &"", position: Vector3 = Vector3.ZERO
) -> void:
_world_id = world_id
_location_id = location_id
_position = position
static func create(
world_id: StringName = &"", location_id: StringName = &"", position: Vector3 = Vector3.ZERO
) -> SpatialAddress:
var address := SpatialAddress.new(world_id, location_id, position)
return address if address.is_valid() else null
static func from_dictionary(record_data: Dictionary) -> SpatialAddress:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not record_data.has_all(["world_id", "location_id", "position"]):
return null
var saved_position = record_data["position"]
if not saved_position is Array or saved_position.size() != 3:
return null
return create(
StringName(record_data["world_id"]),
StringName(record_data["location_id"]),
Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
)
func is_valid() -> bool:
return is_finite(_position.x) and is_finite(_position.y) and is_finite(_position.z)
func get_world_id() -> StringName:
return _world_id
func get_location_id() -> StringName:
return _location_id
func get_position() -> Vector3:
return _position
func index_key() -> String:
var address_parts: Array = [String(_world_id), String(_location_id)]
if _location_id.is_empty():
address_parts.append([_position.x, _position.y, _position.z])
return JSON.stringify(address_parts)
func equals(other: SpatialAddress) -> bool:
return (
other != null
and _world_id == other.get_world_id()
and _location_id == other.get_location_id()
and (_location_id.is_empty() == false or _position == other.get_position())
)
func duplicate_address() -> SpatialAddress:
return SpatialAddress.new(_world_id, _location_id, _position)
func to_dictionary() -> Dictionary:
return {
"schema_version": SCHEMA_VERSION,
"world_id": String(_world_id),
"location_id": String(_location_id),
"position": [_position.x, _position.y, _position.z],
}
+1
View File
@@ -0,0 +1 @@
uid://ngd5wc8yftsv
+63
View File
@@ -0,0 +1,63 @@
class_name WorldEntityRef
extends RefCounted
const SCHEMA_VERSION := 1
var _entity_type: StringName
var _entity_id: StringName
func _init(entity_type: StringName = &"", entity_id: StringName = &"") -> void:
_entity_type = entity_type
_entity_id = entity_id
static func create(entity_type: StringName, entity_id: StringName) -> WorldEntityRef:
var entity_ref := WorldEntityRef.new(entity_type, entity_id)
return entity_ref if entity_ref.is_valid() else null
static func from_dictionary(record_data: Dictionary) -> WorldEntityRef:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not record_data.has_all(["entity_type", "entity_id"]):
return null
return create(StringName(record_data["entity_type"]), StringName(record_data["entity_id"]))
func is_valid() -> bool:
return not _entity_type.is_empty() and not _entity_id.is_empty()
func get_entity_type() -> StringName:
return _entity_type
func get_entity_id() -> StringName:
return _entity_id
func index_key() -> String:
var type_text := String(_entity_type)
var id_text := String(_entity_id)
return "%d:%s%d:%s" % [type_text.length(), type_text, id_text.length(), id_text]
func equals(other: WorldEntityRef) -> bool:
return (
other != null
and _entity_type == other.get_entity_type()
and _entity_id == other.get_entity_id()
)
func duplicate_ref() -> WorldEntityRef:
return WorldEntityRef.new(_entity_type, _entity_id)
func to_dictionary() -> Dictionary:
return {
"schema_version": SCHEMA_VERSION,
"entity_type": String(_entity_type),
"entity_id": String(_entity_id),
}
+1
View File
@@ -0,0 +1 @@
uid://dgy40n4mbhb65
+318
View File
@@ -0,0 +1,318 @@
class_name WorldEventRecord
extends RefCounted
const SCHEMA_VERSION := 1
const ENTITY_TYPE_NPC := &"npc"
const ENTITY_TYPE_WORLD := &"world_entity"
const ROLE_ACTOR := &"actor"
const ROLE_SOURCE := &"source"
const ROLE_DESTINATION := &"destination"
const ECONOMIC_CORE_FIELDS := [
"schema_version",
"event_id",
"event_type",
"tick",
"actor_id",
"source_id",
"destination_id",
"world_position",
]
var _event_id := -1
var _event_type: StringName
var _tick := -1
var _participants: Dictionary = {}
var _location: SpatialAddress
var _payload: Dictionary = {}
static func create(
event_id: int,
event_type: StringName,
tick: int,
participants: Dictionary = {},
location: SpatialAddress = null,
payload: Dictionary = {}
) -> WorldEventRecord:
var normalized_participants: Variant = _normalize_participants(participants)
if normalized_participants == null:
return null
var payload_status := [true]
var normalized_payload: Variant = _normalize_serializable_value(payload, payload_status)
if not payload_status[0] or not normalized_payload is Dictionary:
return null
var event := WorldEventRecord.new()
event._event_id = event_id
event._event_type = event_type
event._tick = tick
event._participants = normalized_participants
event._location = location.duplicate_address() if location != null else null
event._payload = normalized_payload
return event if event.is_valid() else null
static func from_dictionary(record_data: Dictionary) -> WorldEventRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not record_data.has_all(
["event_id", "event_type", "tick", "participants", "location", "payload"]
):
return null
var saved_participants = record_data["participants"]
if not saved_participants is Array:
return null
var participants: Dictionary = {}
for participant_data in saved_participants:
if not participant_data is Dictionary or not participant_data.has_all(["role", "entity"]):
return null
var role := StringName(participant_data["role"])
if (
role.is_empty()
or participants.has(role)
or not participant_data["entity"] is Dictionary
):
return null
var entity_ref := WorldEntityRef.from_dictionary(participant_data["entity"])
if entity_ref == null:
return null
participants[role] = entity_ref
var location: SpatialAddress
var saved_location = record_data["location"]
if saved_location != null:
if not saved_location is Dictionary:
return null
location = SpatialAddress.from_dictionary(saved_location)
if location == null:
return null
if not record_data["payload"] is Dictionary:
return null
return create(
int(record_data["event_id"]),
StringName(record_data["event_type"]),
int(record_data["tick"]),
participants,
location,
record_data["payload"]
)
static func from_economic_event(
economic_event: EconomicEventRecord, world_id: StringName = &"", location_id: StringName = &""
) -> WorldEventRecord:
if economic_event == null:
return null
var economic_data := economic_event.to_dictionary()
var participants: Dictionary = {
ROLE_ACTOR:
WorldEntityRef.create(ENTITY_TYPE_NPC, StringName(str(int(economic_data["actor_id"]))))
}
var source_id := StringName(economic_data["source_id"])
if not source_id.is_empty():
participants[ROLE_SOURCE] = WorldEntityRef.create(ENTITY_TYPE_WORLD, source_id)
var destination_id := StringName(economic_data["destination_id"])
if not destination_id.is_empty():
participants[ROLE_DESTINATION] = WorldEntityRef.create(ENTITY_TYPE_WORLD, destination_id)
var payload: Dictionary = {}
for field in economic_data:
var field_name := String(field)
if field_name in ECONOMIC_CORE_FIELDS:
continue
payload[field_name] = economic_data[field]
var location := SpatialAddress.create(
world_id, location_id, economic_event.get_world_position()
)
return create(
int(economic_data["event_id"]),
StringName(economic_data["event_type"]),
int(economic_data["tick"]),
participants,
location,
payload
)
func is_valid() -> bool:
if _event_id < 0 or _tick < 0 or _event_type.is_empty():
return false
if _location != null and not _location.is_valid():
return false
for role in _participants:
if StringName(role).is_empty():
return false
var entity_ref := _participants[role] as WorldEntityRef
if entity_ref == null or not entity_ref.is_valid():
return false
return true
func get_event_id() -> int:
return _event_id
func get_event_type() -> StringName:
return _event_type
func get_tick() -> int:
return _tick
func has_location() -> bool:
return _location != null
func get_location() -> SpatialAddress:
return _location.duplicate_address() if _location != null else null
func get_payload() -> Dictionary:
return _payload.duplicate(true)
func get_participant(role: StringName) -> WorldEntityRef:
var entity_ref := _participants.get(role) as WorldEntityRef
return entity_ref.duplicate_ref() if entity_ref != null else null
func get_participant_roles() -> Array[StringName]:
var roles: Array[StringName] = []
for role in _participants:
roles.append(StringName(role))
roles.sort_custom(
func(left: StringName, right: StringName) -> bool: return String(left) < String(right)
)
return roles
func get_participant_refs() -> Array[WorldEntityRef]:
var refs: Array[WorldEntityRef] = []
for role in get_participant_roles():
var entity_ref := _participants[role] as WorldEntityRef
refs.append(entity_ref.duplicate_ref())
return refs
func get_participant_index_keys() -> Array[String]:
var keys: Array[String] = []
var seen: Dictionary = {}
for role in get_participant_roles():
var entity_ref := _participants[role] as WorldEntityRef
var key := entity_ref.index_key()
if seen.has(key):
continue
seen[key] = true
keys.append(key)
keys.sort()
return keys
func to_economic_event() -> EconomicEventRecord:
var actor := _participants.get(ROLE_ACTOR) as WorldEntityRef
if actor == null:
return null
var actor_text := String(actor.get_entity_id())
if not actor_text.is_valid_int():
return null
var source := _participants.get(ROLE_SOURCE) as WorldEntityRef
var destination := _participants.get(ROLE_DESTINATION) as WorldEntityRef
var economic_data := _payload.duplicate(true)
economic_data["schema_version"] = EconomicEventRecord.SCHEMA_VERSION
economic_data["event_id"] = _event_id
economic_data["event_type"] = String(_event_type)
economic_data["tick"] = _tick
economic_data["actor_id"] = actor_text.to_int()
economic_data["source_id"] = String(source.get_entity_id()) if source != null else ""
economic_data["destination_id"] = (
String(destination.get_entity_id()) if destination != null else ""
)
economic_data["item_id"] = String(economic_data.get("item_id", ""))
economic_data["amount"] = float(economic_data.get("amount", 0.0))
economic_data["action_id"] = String(economic_data.get("action_id", ""))
economic_data["required_amount"] = float(economic_data.get("required_amount", 0.0))
var position := _location.get_position() if _location != null else Vector3.ZERO
economic_data["world_position"] = [position.x, position.y, position.z]
return EconomicEventRecord.from_dictionary(economic_data)
func to_dictionary() -> Dictionary:
var serialized_participants: Array[Dictionary] = []
for role in get_participant_roles():
var entity_ref := _participants[role] as WorldEntityRef
(
serialized_participants
. append(
{
"role": String(role),
"entity": entity_ref.to_dictionary(),
}
)
)
return {
"schema_version": SCHEMA_VERSION,
"event_id": _event_id,
"event_type": String(_event_type),
"tick": _tick,
"participants": serialized_participants,
"location": _location.to_dictionary() if _location != null else null,
"payload": _payload.duplicate(true),
}
static func _normalize_participants(participants: Dictionary) -> Variant:
var normalized: Dictionary = {}
var role_names: Dictionary = {}
for raw_role in participants:
if not raw_role is String and not raw_role is StringName:
return null
var role := StringName(raw_role)
var role_text := String(role)
if role.is_empty() or role_names.has(role_text):
return null
var entity_ref := participants[raw_role] as WorldEntityRef
if entity_ref == null or not entity_ref.is_valid():
return null
role_names[role_text] = true
normalized[role] = entity_ref.duplicate_ref()
return normalized
static func _normalize_serializable_value(value: Variant, status: Array) -> Variant:
match typeof(value):
TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_STRING:
return value
TYPE_FLOAT:
if not is_finite(float(value)):
status[0] = false
return value
TYPE_STRING_NAME:
return String(value)
TYPE_ARRAY:
var normalized_array: Array = []
for element in value:
normalized_array.append(_normalize_serializable_value(element, status))
return normalized_array
TYPE_DICTIONARY:
return _normalize_serializable_dictionary(value, status)
_:
status[0] = false
return null
static func _normalize_serializable_dictionary(value: Dictionary, status: Array) -> Dictionary:
var source_keys: Dictionary = {}
for raw_key in value:
if not raw_key is String and not raw_key is StringName:
status[0] = false
return {}
var key_text := String(raw_key)
if source_keys.has(key_text):
status[0] = false
return {}
source_keys[key_text] = raw_key
var sorted_keys: Array = source_keys.keys()
sorted_keys.sort()
var normalized: Dictionary = {}
for key_text in sorted_keys:
var raw_key = source_keys[key_text]
normalized[key_text] = _normalize_serializable_value(value[raw_key], status)
return normalized
+1
View File
@@ -0,0 +1 @@
uid://c2txwx7jgo8rx
+270
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cvwpt4l3vp4ir
+172
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://1tp4ttukj4bv
+256
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://d3linx25d7web
+52
View File
@@ -0,0 +1,52 @@
class_name WorldTargetCapability
extends RefCounted
var _capability_id: StringName
var _attributes: Dictionary
func _init(capability_id: StringName = &"", attributes: Dictionary = {}) -> void:
_capability_id = capability_id
_attributes = attributes.duplicate(true)
func is_valid() -> bool:
return not _capability_id.is_empty() and not _contains_object(_attributes)
func get_capability_id() -> StringName:
return _capability_id
func get_attributes() -> Dictionary:
return _attributes.duplicate(true)
func get_attribute(attribute_id: StringName, default_value: Variant = null) -> Variant:
var value: Variant = _attributes.get(String(attribute_id), default_value)
return value.duplicate(true) if value is Array or value is Dictionary else value
func to_dictionary() -> Dictionary:
return {
"capability_id": String(_capability_id),
"attributes": _attributes.duplicate(true),
}
func copy() -> WorldTargetCapability:
return WorldTargetCapability.new(_capability_id, _attributes)
static func _contains_object(value: Variant) -> bool:
if value is Object:
return true
if value is Array:
for item in value:
if _contains_object(item):
return true
elif value is Dictionary:
for key in value:
if _contains_object(key) or _contains_object(value[key]):
return true
return false
@@ -0,0 +1 @@
uid://cfs6efk5rokcp
+126
View File
@@ -0,0 +1,126 @@
class_name WorldTargetDescriptor
extends RefCounted
var _target_id: StringName
var _target_kind: StringName
var _display_name: String
var _local_position: Vector3
var _metadata: Dictionary
var _capabilities_by_id: Dictionary = {}
var _capability_ids: Array[StringName] = []
var _is_valid := false
func _init(
target_id: StringName = &"",
target_kind: StringName = &"",
capabilities: Array = [],
local_position: Vector3 = Vector3.ZERO,
display_name: String = "",
metadata: Dictionary = {}
) -> void:
_target_id = target_id
_target_kind = target_kind
_display_name = display_name
_local_position = local_position
_metadata = metadata.duplicate(true)
_is_valid = (
not _target_id.is_empty()
and not _target_kind.is_empty()
and _local_position.is_finite()
and not WorldTargetCapability._contains_object(_metadata)
)
for value in capabilities:
var capability := value as WorldTargetCapability
if (
capability == null
or not capability.is_valid()
or _capabilities_by_id.has(capability.get_capability_id())
):
_is_valid = false
continue
var isolated_capability := capability.copy()
_capabilities_by_id[isolated_capability.get_capability_id()] = isolated_capability
_capability_ids.append(isolated_capability.get_capability_id())
_capability_ids.sort_custom(_id_less)
func is_valid() -> bool:
return _is_valid
func get_target_id() -> StringName:
return _target_id
func get_target_kind() -> StringName:
return _target_kind
func get_display_name() -> String:
return _display_name
func get_local_position() -> Vector3:
return _local_position
func get_metadata() -> Dictionary:
return _metadata.duplicate(true)
func get_metadata_value(metadata_id: StringName, default_value: Variant = null) -> Variant:
var value: Variant = _metadata.get(String(metadata_id), default_value)
return value.duplicate(true) if value is Array or value is Dictionary else value
func has_capability(capability_id: StringName) -> bool:
return _capabilities_by_id.has(capability_id)
func get_capability(capability_id: StringName) -> WorldTargetCapability:
var capability := _capabilities_by_id.get(capability_id) as WorldTargetCapability
return capability.copy() if capability != null else null
func get_capability_ids() -> Array[StringName]:
return _capability_ids.duplicate()
func get_capabilities() -> Array[WorldTargetCapability]:
var capabilities: Array[WorldTargetCapability] = []
for capability_id in _capability_ids:
capabilities.append((_capabilities_by_id[capability_id] as WorldTargetCapability).copy())
return capabilities
func copy() -> WorldTargetDescriptor:
return WorldTargetDescriptor.new(
_target_id, _target_kind, get_capabilities(), _local_position, _display_name, _metadata
)
func with_local_position(next_position: Vector3) -> WorldTargetDescriptor:
return WorldTargetDescriptor.new(
_target_id, _target_kind, get_capabilities(), next_position, _display_name, _metadata
)
func to_dictionary() -> Dictionary:
var capabilities: Array[Dictionary] = []
for capability_id in _capability_ids:
capabilities.append(
(_capabilities_by_id[capability_id] as WorldTargetCapability).to_dictionary()
)
return {
"target_id": String(_target_id),
"target_kind": String(_target_kind),
"display_name": _display_name,
"local_position": [_local_position.x, _local_position.y, _local_position.z],
"metadata": _metadata.duplicate(true),
"capabilities": capabilities,
}
static func _id_less(first: StringName, second: StringName) -> bool:
return String(first) < String(second)
@@ -0,0 +1 @@
uid://bjvptxki1v7q0
+52
View File
@@ -0,0 +1,52 @@
class_name WorldTargetHandle
extends RefCounted
var _registry_instance_id: int
var _context_id: StringName
var _target_id: StringName
var _target_kind: StringName
var _generation: int
func _init(
registry_instance_id: int = 0,
context_id: StringName = &"",
target_id: StringName = &"",
target_kind: StringName = &"",
generation: int = 0
) -> void:
_registry_instance_id = registry_instance_id
_context_id = context_id
_target_id = target_id
_target_kind = target_kind
_generation = generation
func is_well_formed() -> bool:
return (
_registry_instance_id != 0
and not _context_id.is_empty()
and not _target_id.is_empty()
and not _target_kind.is_empty()
and _generation > 0
)
func get_registry_instance_id() -> int:
return _registry_instance_id
func get_context_id() -> StringName:
return _context_id
func get_target_id() -> StringName:
return _target_id
func get_target_kind() -> StringName:
return _target_kind
func get_generation() -> int:
return _generation
+1
View File
@@ -0,0 +1 @@
uid://dcun0sd0f8ext
+263
View File
@@ -0,0 +1,263 @@
class_name WorldTargetRegistry
extends RefCounted
var last_error := ""
var _context_id: StringName
var _entries_by_id: Dictionary = {}
var _generation_by_id: Dictionary = {}
var _sorted_target_ids: Array[StringName] = []
var _target_ids_by_kind: Dictionary = {}
var _target_ids_by_capability: Dictionary = {}
func _init(owned_context_id: StringName = &"") -> void:
_context_id = owned_context_id
func register_target(
descriptor: WorldTargetDescriptor, provider: Object = null
) -> WorldTargetHandle:
last_error = ""
if _context_id.is_empty():
return _fail_handle("Registry context_id must not be empty")
if descriptor == null or not descriptor.is_valid():
return _fail_handle("Target descriptor is invalid")
if provider != null and not is_instance_valid(provider):
return _fail_handle("Target provider is invalid")
var target_id := descriptor.get_target_id()
if _entries_by_id.has(target_id):
var existing := _entries_by_id[target_id]["descriptor"] as WorldTargetDescriptor
return _fail_handle(
(
"Target ID '%s' is already registered as kind '%s'"
% [target_id, existing.get_target_kind()]
)
)
var isolated_descriptor := descriptor.copy()
var generation := int(_generation_by_id.get(target_id, 0)) + 1
_generation_by_id[target_id] = generation
_entries_by_id[target_id] = {
"descriptor": isolated_descriptor,
"generation": generation,
"has_provider": provider != null,
"provider_ref": weakref(provider) if provider != null else null,
}
_insert_sorted_unique(_sorted_target_ids, target_id)
_add_to_index(_target_ids_by_kind, isolated_descriptor.get_target_kind(), target_id)
for capability_id in isolated_descriptor.get_capability_ids():
_add_to_index(_target_ids_by_capability, capability_id, target_id)
return _make_handle(isolated_descriptor, generation)
func update_target(handle: WorldTargetHandle, descriptor: WorldTargetDescriptor) -> bool:
last_error = ""
var entry := _get_handle_entry(handle)
if entry.is_empty():
return _fail_bool("Target handle is stale or belongs to another registry")
if descriptor == null or not descriptor.is_valid():
return _fail_bool("Target descriptor is invalid")
var previous := entry["descriptor"] as WorldTargetDescriptor
if (
descriptor.get_target_id() != previous.get_target_id()
or descriptor.get_target_kind() != previous.get_target_kind()
):
return _fail_bool("Target identity and kind cannot change during an update")
for capability_id in previous.get_capability_ids():
_remove_from_index(_target_ids_by_capability, capability_id, previous.get_target_id())
var isolated_descriptor := descriptor.copy()
entry["descriptor"] = isolated_descriptor
_entries_by_id[isolated_descriptor.get_target_id()] = entry
for capability_id in isolated_descriptor.get_capability_ids():
_add_to_index(_target_ids_by_capability, capability_id, isolated_descriptor.get_target_id())
return true
func unregister_target(handle: WorldTargetHandle) -> bool:
last_error = ""
var entry := _get_handle_entry(handle)
if entry.is_empty():
return _fail_bool("Target handle is stale or belongs to another registry")
_erase_entry(handle.get_target_id(), entry)
return true
func clear() -> void:
for target_id in _sorted_target_ids.duplicate():
_erase_entry(target_id, _entries_by_id[target_id])
last_error = ""
func prune_invalid_providers() -> Array[StringName]:
var removed_ids: Array[StringName] = []
for target_id in _sorted_target_ids.duplicate():
var entry: Dictionary = _entries_by_id[target_id]
if not bool(entry["has_provider"]):
continue
var provider_ref := entry["provider_ref"] as WeakRef
if provider_ref != null and provider_ref.get_ref() != null:
continue
_erase_entry(target_id, entry)
removed_ids.append(target_id)
return removed_ids
func has_target(target_id: StringName) -> bool:
return _entries_by_id.has(target_id)
func get_context_id() -> StringName:
return _context_id
func size() -> int:
return _entries_by_id.size()
func get_descriptor(target_id: StringName) -> WorldTargetDescriptor:
var entry: Dictionary = _entries_by_id.get(target_id, {})
return entry.get("descriptor") as WorldTargetDescriptor
func get_handle(target_id: StringName) -> WorldTargetHandle:
var entry: Dictionary = _entries_by_id.get(target_id, {})
if entry.is_empty():
return null
return _make_handle(entry["descriptor"] as WorldTargetDescriptor, int(entry["generation"]))
func resolve_handle(handle: WorldTargetHandle) -> WorldTargetDescriptor:
var entry := _get_handle_entry(handle)
return entry.get("descriptor") as WorldTargetDescriptor
func is_handle_valid(handle: WorldTargetHandle, require_provider: bool = false) -> bool:
var entry := _get_handle_entry(handle)
if entry.is_empty():
return false
if not require_provider:
return true
if not bool(entry["has_provider"]):
return false
var provider_ref := entry["provider_ref"] as WeakRef
return provider_ref != null and provider_ref.get_ref() != null
func get_provider(handle: WorldTargetHandle) -> Object:
var entry := _get_handle_entry(handle)
if entry.is_empty() or not bool(entry["has_provider"]):
return null
var provider_ref := entry["provider_ref"] as WeakRef
return provider_ref.get_ref() if provider_ref != null else null
func get_target_ids(
target_kind: StringName = &"", capability_id: StringName = &""
) -> Array[StringName]:
var source_ids: Array = _sorted_target_ids
if not capability_id.is_empty():
source_ids = _target_ids_by_capability.get(capability_id, [])
elif not target_kind.is_empty():
source_ids = _target_ids_by_kind.get(target_kind, [])
var result: Array[StringName] = []
for target_id in source_ids:
var descriptor := get_descriptor(target_id)
if descriptor == null:
continue
if not target_kind.is_empty() and descriptor.get_target_kind() != target_kind:
continue
if not capability_id.is_empty() and not descriptor.has_capability(capability_id):
continue
result.append(target_id)
return result
func get_descriptors(
target_kind: StringName = &"", capability_id: StringName = &""
) -> Array[WorldTargetDescriptor]:
var descriptors: Array[WorldTargetDescriptor] = []
for target_id in get_target_ids(target_kind, capability_id):
descriptors.append(get_descriptor(target_id))
return descriptors
func _get_handle_entry(handle: WorldTargetHandle) -> Dictionary:
if (
handle == null
or not handle.is_well_formed()
or handle.get_registry_instance_id() != get_instance_id()
or handle.get_context_id() != _context_id
):
return {}
var entry: Dictionary = _entries_by_id.get(handle.get_target_id(), {})
if entry.is_empty():
return {}
var descriptor := entry["descriptor"] as WorldTargetDescriptor
if (
int(entry["generation"]) != handle.get_generation()
or descriptor.get_target_kind() != handle.get_target_kind()
):
return {}
return entry
func _make_handle(descriptor: WorldTargetDescriptor, generation: int) -> WorldTargetHandle:
return WorldTargetHandle.new(
get_instance_id(),
_context_id,
descriptor.get_target_id(),
descriptor.get_target_kind(),
generation
)
func _erase_entry(target_id: StringName, entry: Dictionary) -> void:
var descriptor := entry["descriptor"] as WorldTargetDescriptor
_sorted_target_ids.erase(target_id)
_remove_from_index(_target_ids_by_kind, descriptor.get_target_kind(), target_id)
for capability_id in descriptor.get_capability_ids():
_remove_from_index(_target_ids_by_capability, capability_id, target_id)
_entries_by_id.erase(target_id)
func _add_to_index(index: Dictionary, index_id: StringName, target_id: StringName) -> void:
var target_ids: Array = index.get(index_id, [])
_insert_sorted_unique(target_ids, target_id)
index[index_id] = target_ids
func _remove_from_index(index: Dictionary, index_id: StringName, target_id: StringName) -> void:
var target_ids: Array = index.get(index_id, [])
target_ids.erase(target_id)
if target_ids.is_empty():
index.erase(index_id)
else:
index[index_id] = target_ids
func _insert_sorted_unique(target_ids: Array, target_id: StringName) -> void:
var low := 0
var high := target_ids.size()
var target_text := String(target_id)
while low < high:
var middle := (low + high) / 2
var middle_text := String(target_ids[middle])
if middle_text < target_text:
low = middle + 1
else:
high = middle
if low < target_ids.size() and String(target_ids[low]) == target_text:
return
target_ids.insert(low, target_id)
func _fail_handle(message: String) -> WorldTargetHandle:
last_error = message
return null
func _fail_bool(message: String) -> bool:
last_error = message
return false
+1
View File
@@ -0,0 +1 @@
uid://b6ok1rowe60rg