feat: derive capable opportunity helpers

This commit is contained in:
Rijad Zuzo
2026-07-16 10:41:05 +02:00
parent 79dd503343
commit eaa15c076c
17 changed files with 648 additions and 40 deletions
+3 -1
View File
@@ -43,7 +43,9 @@ would otherwise obscure that lifecycle:
- `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable
event references plus current NPC/storage state, then owns the bounded shared
open/resolved/invalidated lifecycle for the proven pantry-food and blocked-
work wood consumers without changing resources or assigning tasks;
work wood consumers. It also derives one ephemeral `OpportunityHelperResult`
from knowledge, directed relationships, inventory, action definitions, and
finite-resource state without changing resources or assigning tasks;
- `simulation/persistence/` owns save-slot file safety;
- `simulation/state/` owns versioned serialized record contracts;
- `simulation/definitions/` owns stable IDs and immutable action/profession
+9 -4
View File
@@ -731,13 +731,18 @@ Completed:
player tree-harvest resolutions use real economy history; interested-party
death and one-day staleness close the need cleanly. The compact UI explains
the blocked action without reusing the pantry's hunger cue.
28. Explainable capable helper: an open food or wood need now names one
deterministic informed and trusted villager plus the ordinary gather or
deposit action and real inventory/resource route that makes them capable.
The result is re-derived after restore and does not assign work or alter the
simulation.
Next:
1. Add the first read-only capable-helper query for an open need. Expose one
deterministic viable villager and the real knowledge, relationship, action,
inventory, and resource facts behind that result before allowing it to
affect autonomous selection, as sequenced in `LEARNING_ROADMAP.md`.
1. Let the currently derived helper consume the query through ordinary
autonomous action selection, after urgent self-care and schedule precedence.
Re-query instead of persisting assignment, and keep acceptance/rewards out
of scope, as sequenced in `LEARNING_ROADMAP.md`.
Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already
+19 -3
View File
@@ -99,6 +99,19 @@ opening; invalidation emits no event and stores the close tick and stable
reason. Resolution or invalidation returns the trigger to normal bounded
retention.
For an open need, the same system can derive one capable helper without
assigning work. A candidate must know the exact trigger and have a familiar
directed relationship with at least 0.6 trust toward the interested villager.
Enough carried inventory produces the matching deposit route; otherwise a
matching enabled, NPC-usable, sufficiently stocked and available finite
ResourceNode state produces the gather route. Ranking is deterministic and
prefers ready inventory, trust, profession fit, familiarity, then stable NPC
ID. The returned opportunity/trigger/helper/action/resource, inventory source
or available finite-source count, and reason are an ephemeral query result. It
does not select an exact gather target; that remains with `ActiveWorldAdapter`
and `ActionTargetResolver`. No event, reservation, RNG draw, task mutation, or
serialized assignment is created.
The food-loop regression verifies this chain:
```text
@@ -123,7 +136,9 @@ The village summary also shows one compact active need with its real target
progress and interested villager; only that villager's inspector retains its
open/closed detail and names the exact supplier for a resolution. The empty-bowl
world cue remains specific to the pantry shortage rather than representing a
wood need as hunger.
wood need as hunger. The active summary additionally names the currently
derived helper, supply action, trust fact, and inventory or finite-source route,
or states that no informed trusted villager is currently able to supply it.
## Deliberate limits
@@ -136,5 +151,6 @@ hearing, personalized reinforcement/decay, multi-hop rumours, secrecy, false
beliefs, and multi-event causal graphs belong in later event/history slices.
They should extend this record family without making prose authoritative or
recomputing old evidence from current positions.
The current opportunity family is likewise a bounded two-consumer projection,
not a generic quest, reward, acceptance, dialogue, or capable-helper framework.
The current opportunity family is likewise a bounded two-consumer projection
with one read-only helper query, not a generic quest, reward, acceptance,
dialogue, helper assignment, or capable-helper framework.
+22 -5
View File
@@ -775,11 +775,28 @@ The second bounded Milestone 7 consumer is complete:
- the two proven consumers now share `VillageOpportunitySystem` and the common
lifecycle fields, while their evidence and care rules remain explicit.
Milestone 7 is not complete. The immediate next slice is a bounded
capable-helper query for an open need: derive one viable helper from existing
knowledge, relationship, action, inventory, and resource facts and expose the
reason for that result. Keep it read-only first; only a later proven slice
should let opportunity awareness influence autonomous task selection.
The first bounded capable-helper query is complete:
- an open need derives at most one living helper who knows its exact trigger,
has an existing familiar directed relationship with at least 0.6 trust toward
the interested villager, and is not that villager;
- the helper must either carry enough matching inventory to reach the target or
have a matching enabled, NPC-usable, sufficiently stocked finite resource
that is not reserved by someone else;
- ready inventory wins first, followed by higher trust, matching gather
profession, familiarity, and lowest stable NPC ID;
- `OpportunityHelperResult` exposes the exact opportunity, trigger, helper,
action, inventory source or finite-source count, resource, relationship
values, and concise reason without mutating RNG, tasks, reservations,
resources, or persisted state;
- food and wood scenarios re-derive the same result after restore, and the
village summary explains the current helper route from real simulation facts.
Milestone 7 is not complete. The immediate next slice is one bounded autonomous
consumer of this query: let the currently derived helper choose the reported
gather/deposit action through ordinary selection while preserving urgent
self-care and schedule precedence. Re-query rather than persisting assignment,
and do not add quest acceptance or reward state.
Recently completed:
+15 -6
View File
@@ -618,8 +618,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
- The bounded opportunity family supports one open `restock_empty_pantry` or
`supply_missing_wood` need from exact known evidence. Both have exact NPC and
player supply resolutions; the wood need also closes deterministically on
interested-party death or one-day staleness. There is still no capable-helper
assignment, acceptance, rewards, free-form dialogue, or quest log.
interested-party death or one-day staleness. One read-only query derives and
explains a capable helper, but there is still no helper assignment,
acceptance, rewards, free-form dialogue, or quest log.
- The reason inspector exposes current decisions, utility rejections, one exact
relationship cause, and a compact person-history view that distinguishes
importance-ranked retained memories from objective personal actions.
@@ -902,10 +903,18 @@ v9 and nested opportunity schema v2 preserve open, resolved, and invalidated
history. The two consumers now share `VillageOpportunitySystem`, while their
evidence and care rules remain explicit.
Milestone 7 remains in progress. The immediate next slice is a read-only,
deterministic capable-helper query for an open need, derived from existing
knowledge, relationship, action, inventory, and resource facts before it is
allowed to influence autonomous task selection.
The first capable-helper query is complete. An open food or wood need now
derives one living informed villager with a familiar trusted directed tie and a
real inventory-deposit or finite-resource gather route. Inventory readiness,
trust, profession fit, familiarity, finite-source availability, and stable IDs
produce one deterministic result. The village summary explains that result;
save/load re-derives it, and querying changes no task, reservation, RNG stream,
resource, event, or checksum.
Milestone 7 remains in progress. The immediate next slice is one bounded
autonomous consumer: allow the currently derived helper to choose its reported
ordinary supply action after urgent self-care and schedule rules, without
persisted assignment, quest acceptance, or reward state.
The remaining simulation-garden target still aims for:
+9 -1
View File
@@ -196,6 +196,12 @@ lasting during deterministic memory maintenance. Resolution or invalidation
releases that fact back to the normal bounded-retention rules; neither deletes
objective event or opportunity history.
`OpportunityHelperResult` is deliberately absent from this schema. It is
re-derived from the open opportunity plus current NPC knowledge, directed
relationship, inventory, action-definition, storage-capacity, and finite-
resource records. Save/restore regressions require the same helper/action/route
result and an unchanged checksum before and after querying.
## Resource authority
`SimulationManager` owns `ResourceStateRecord` instances independently of the
@@ -212,7 +218,9 @@ defaults.
ResourceStateRecord v3 adds `safety_risk`, `comfort_distance`, and
`discovery_priority` so unloaded resources keep the same target-selection
meaning after save/restore. Nested v1 and v2 resource records are migrated
explicitly.
explicitly. Current-schema parsing also canonicalizes numeric, boolean, and ID
variants so a freshly registered finite source retains a byte-stable checksum
after a JSON round trip.
## Local quicksave boundary
+18 -16
View File
@@ -806,23 +806,32 @@ func get_active_opportunity() -> OpportunityStateRecord:
return opportunity_system.get_open_opportunity()
func get_active_opportunity_helper() -> OpportunityHelperResult:
var active := get_active_opportunity()
if active == null:
return null
return opportunity_system.find_capable_helper(
active,
economy.get_storage(active.get_target_id()),
npcs,
event_knowledge_system,
relationship_system,
resource_states
)
func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord:
return opportunity_system.get_latest_for_npc(npc_id)
func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
if opportunity == null:
return null
return event_log.get_by_id(opportunity.get_trigger_event_id())
var event_id := opportunity.get_trigger_event_id() if opportunity != null else -1
return event_log.get_by_id(event_id)
func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
if (
opportunity == null
or opportunity.get_resolution_event_id() == OpportunityStateRecord.NO_EVENT_ID
):
return null
return event_log.get_by_id(opportunity.get_resolution_event_id())
var event_id := opportunity.get_resolution_event_id() if opportunity != null else -1
return event_log.get_by_id(event_id)
func npc_knows_event(npc_id: int, event_id: int) -> bool:
@@ -1106,7 +1115,6 @@ func create_state_record() -> SimulationStateRecord:
wander_streams.append(
{"npc_id": int(npc_id), "seed": str(source.seed), "state": str(source.state)}
)
record.simulation = {
"seed": simulation_seed,
"tick_interval": tick_interval,
@@ -1121,7 +1129,6 @@ func create_state_record() -> SimulationStateRecord:
record.village = VillageStateRecord.capture(village)
for npc in npcs:
record.npcs.append(NPCStateRecord.capture(npc))
var sorted_resource_ids: Array = resource_states.keys()
sorted_resource_ids.sort()
for resource_id in sorted_resource_ids:
@@ -1158,7 +1165,6 @@ func restore_state_from_json(json_text: String) -> bool:
func restore_state(record: SimulationStateRecord) -> bool:
if record == null:
return false
simulation_seed = int(record.simulation["seed"])
tick_interval = float(record.simulation["tick_interval"])
tick_count = int(record.simulation["tick_count"])
@@ -1171,7 +1177,6 @@ func restore_state(record: SimulationStateRecord) -> bool:
economy.restore_storage(record.storages)
event_log.restore(record.economic_events, int(record.simulation["next_event_id"]))
latest_decisions.clear()
npcs.clear()
for npc_record in record.npcs:
npcs.append(npc_record.restore(debug_logs))
@@ -1179,7 +1184,6 @@ func restore_state(record: SimulationStateRecord) -> bool:
event_knowledge_system.restore(record.event_knowledge)
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
wander_random_sources.clear()
var wander_streams: Array = record.simulation["wander_random_streams"]
for stream_data in wander_streams:
@@ -1187,12 +1191,10 @@ func restore_state(record: SimulationStateRecord) -> bool:
source.seed = String(stream_data["seed"]).to_int()
source.state = String(stream_data["state"]).to_int()
wander_random_sources[int(stream_data["npc_id"])] = source
resource_states.clear()
for resource_record in record.resources:
resource_states[resource_record.get_node_id()] = resource_record
register_loaded_resource_nodes()
village_changed.emit(village)
state_restored.emit()
return true
@@ -0,0 +1,30 @@
class_name OpportunityHelperResult
extends RefCounted
var opportunity_id: int
var helper_npc_id: int
var action_id: StringName
var source_id: StringName
var resource_id: StringName
var trigger_event_id: int
var trust: float
var familiarity: float
var uses_inventory: bool
var available_source_count: int
var profession_match: bool
var reason: String
func _init(result: Dictionary) -> void:
opportunity_id = int(result["opportunity_id"])
helper_npc_id = int(result["helper_npc_id"])
action_id = StringName(result["action_id"])
source_id = StringName(result["source_id"])
resource_id = StringName(result["resource_id"])
trigger_event_id = int(result["trigger_event_id"])
trust = float(result["trust"])
familiarity = float(result["familiarity"])
uses_inventory = bool(result["uses_inventory"])
available_source_count = int(result["available_source_count"])
profession_match = bool(result["profession_match"])
reason = String(result["reason"])
@@ -0,0 +1 @@
uid://o8t4og3iq8dv
@@ -112,6 +112,196 @@ func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord:
return null
func find_capable_helper(
opportunity: OpportunityStateRecord,
target_storage: StorageStateRecord,
npcs: Array[SimNPC],
knowledge_system: EventKnowledgeSystem,
relationship_system: RefCounted,
resource_states: Dictionary
) -> OpportunityHelperResult:
if (
opportunity == null
or not opportunity.is_open()
or target_storage == null
or knowledge_system == null
or relationship_system == null
):
return null
var interested := _find_npc(opportunity.get_interested_npc_id(), npcs)
if interested == null or interested.is_dead:
return null
var remaining := maxf(
opportunity.get_target_amount() - target_storage.get_amount(opportunity.get_resource_id()),
0.0
)
if remaining <= 0.0 or target_storage.get_available_capacity() < remaining:
return null
var action_ids := _get_supply_action_ids(opportunity.get_resource_id())
if action_ids.is_empty():
return null
var gather_definition := SimulationDefinitions.get_action(action_ids["gather"])
var deposit_definition := SimulationDefinitions.get_action(action_ids["deposit"])
if gather_definition == null or deposit_definition == null:
return null
var candidates: Array[Dictionary] = []
for npc in npcs:
var candidate := _build_helper_candidate(
npc,
opportunity,
remaining,
gather_definition,
deposit_definition,
interested.npc_name,
knowledge_system,
relationship_system,
resource_states
)
if not candidate.is_empty():
candidates.append(candidate)
if candidates.is_empty():
return null
candidates.sort_custom(_sort_helper_candidates)
var best := candidates[0]
return (
OpportunityHelperResult
. new(
{
"opportunity_id": opportunity.get_opportunity_id(),
"helper_npc_id": (best["npc"] as SimNPC).id,
"action_id": best["action_id"],
"source_id": best["source_id"],
"resource_id": opportunity.get_resource_id(),
"trigger_event_id": opportunity.get_trigger_event_id(),
"trust": best["trust"],
"familiarity": best["familiarity"],
"uses_inventory": best["uses_inventory"],
"available_source_count": best["available_source_count"],
"profession_match": best["profession_match"],
"reason": best["reason"],
}
)
)
func _build_helper_candidate(
npc: SimNPC,
opportunity: OpportunityStateRecord,
remaining: float,
gather_definition: ActionDefinition,
deposit_definition: ActionDefinition,
interested_name: String,
knowledge_system: EventKnowledgeSystem,
relationship_system: RefCounted,
resource_states: Dictionary
) -> Dictionary:
if npc.is_dead or npc.id == opportunity.get_interested_npc_id():
return {}
if not knowledge_system.knows_event(npc.id, opportunity.get_trigger_event_id()):
return {}
var relationship: RelationshipStateRecord = relationship_system.get_relationship(
npc.id, opportunity.get_interested_npc_id()
)
if (
relationship == null
or not relationship_system.is_trusted_for_help(npc.id, opportunity.get_interested_npc_id())
):
return {}
var carried := npc.get_inventory_amount(opportunity.get_resource_id())
var uses_inventory := carried >= remaining
var available_source_count := 0
if not uses_inventory:
available_source_count = _count_available_resource_sources(
npc.id, remaining, opportunity.get_resource_id(), gather_definition, resource_states
)
if available_source_count == 0:
return {}
var action_definition := deposit_definition if uses_inventory else gather_definition
var source_id := SimulationIds.npc_inventory_id(npc.id) if uses_inventory else &""
var profession_match := (
not gather_definition.preferred_profession_id.is_empty()
and npc.profession == gather_definition.preferred_profession_id
)
var supply_fact := (
"carries %.1f %s" % [carried, String(opportunity.get_resource_id()).capitalize()]
if uses_inventory
else (
"has %d available finite %s source%s"
% [
available_source_count,
String(opportunity.get_resource_id()).capitalize(),
"" if available_source_count == 1 else "s",
]
)
)
return {
"npc": npc,
"action_id": action_definition.action_id,
"source_id": source_id,
"trust": relationship.get_trust(),
"familiarity": relationship.get_familiarity(),
"uses_inventory": uses_inventory,
"available_source_count": available_source_count,
"profession_match": profession_match,
"reason":
(
"Knows the need; trust %.2f toward %s; %s"
% [relationship.get_trust(), interested_name, supply_fact]
),
}
static func _get_supply_action_ids(resource_id: StringName) -> Dictionary:
match resource_id:
SimulationIds.RESOURCE_FOOD:
return {
"gather": SimulationIds.ACTION_GATHER_FOOD,
"deposit": SimulationIds.ACTION_DEPOSIT_FOOD,
}
SimulationIds.RESOURCE_WOOD:
return {
"gather": SimulationIds.ACTION_GATHER_WOOD,
"deposit": SimulationIds.ACTION_DEPOSIT_WOOD,
}
return {}
static func _count_available_resource_sources(
npc_id: int,
remaining: float,
resource_id: StringName,
gather_definition: ActionDefinition,
resource_states: Dictionary
) -> int:
var count := 0
for value in resource_states.values():
var state := value as ResourceStateRecord
if (
state == null
or state.get_action_id() != gather_definition.resource_action_id
or state.get_resource_id() != resource_id
or not state.can_npc_use()
or not state.is_available_for(npc_id)
or state.get_yield_per_action() <= 0.0
or state.get_amount_remaining() < remaining
):
continue
count += 1
return count
static func _sort_helper_candidates(first: Dictionary, second: Dictionary) -> bool:
if first["uses_inventory"] != second["uses_inventory"]:
return bool(first["uses_inventory"])
if first["trust"] != second["trust"]:
return float(first["trust"]) > float(second["trust"])
if first["profession_match"] != second["profession_match"]:
return bool(first["profession_match"])
if first["familiarity"] != second["familiarity"]:
return float(first["familiarity"]) > float(second["familiarity"])
return (first["npc"] as SimNPC).id < (second["npc"] as SimNPC).id
func _try_open_food(
event: EconomicEventRecord,
current_tick: int,
@@ -38,6 +38,15 @@ func get_relationship(observer_id: int, subject_id: int) -> RelationshipStateRec
return relationships.get(_key(observer_id, subject_id)) as RelationshipStateRecord
func is_trusted_for_help(observer_id: int, subject_id: int) -> bool:
var relationship := get_relationship(observer_id, subject_id)
return (
relationship != null
and relationship.get_familiarity() > 0.0
and relationship.get_trust() >= TRUSTED_HELP_THRESHOLD
)
func increase_shared_work_familiarity(first_id: int, second_id: int) -> void:
var first_to_second := _get_or_create(first_id, second_id)
var second_to_first := _get_or_create(second_id, first_id)
+15 -1
View File
@@ -66,7 +66,21 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord:
var action_id := StringName(record_data["action_id"])
if not action_id.is_empty() and SimulationDefinitions.get_action(action_id) == null:
return null
return ResourceStateRecord.new(record_data)
var normalized := record_data.duplicate(true)
normalized["schema_version"] = SCHEMA_VERSION
normalized["node_id"] = String(record_data["node_id"])
normalized["action_id"] = String(record_data["action_id"])
normalized["resource_id"] = String(record_data["resource_id"])
normalized["amount_remaining"] = float(record_data["amount_remaining"])
normalized["yield_per_action"] = float(record_data["yield_per_action"])
normalized["reserved_by"] = int(record_data["reserved_by"])
normalized["enabled"] = bool(record_data["enabled"])
normalized["can_npcs_use"] = bool(record_data["can_npcs_use"])
normalized["can_player_use"] = bool(record_data["can_player_use"])
normalized["safety_risk"] = float(record_data["safety_risk"])
normalized["comfort_distance"] = float(record_data["comfort_distance"])
normalized["discovery_priority"] = float(record_data["discovery_priority"])
return ResourceStateRecord.new(normalized)
static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
+26
View File
@@ -239,6 +239,8 @@ func _run() -> void:
)
await process_frame
contributor = simulation_manager.npcs[contributor.id]
witness = simulation_manager.npcs[witness.id]
listener = simulation_manager.npcs[listener.id]
contributor_visual = world_view.active_npc_visuals[contributor.id]
player_opportunity = simulation_manager.get_active_opportunity()
_check(
@@ -277,15 +279,39 @@ func _run() -> void:
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
)
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
var woodpile_node := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
)
contributor.position = woodpile_node.get_interaction_position()
witness.position = contributor.position
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
contributor.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
contributor.travel_target_position = contributor.position
contributor.start_working()
simulation_manager.simulate_tick()
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
_check(
(
runtime_helper != null
and runtime_helper.helper_npc_id == witness.id
and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD
),
"Runtime helper query should derive the informed trusted woodcutter"
)
var runtime_helper_reason := (
"Knows the need; trust %.2f toward %s" % [runtime_helper.trust, contributor.npc_name]
if runtime_helper != null
else "missing helper"
)
_check(
(
"◆ Supply wood for blocked work" in village_stats_label.text
and "could not finish Study" in village_stats_label.text
and "Possible helper: %s — Gather Wood" % witness.npc_name in village_stats_label.text
and runtime_helper_reason in village_stats_label.text
and "◆ Find wood for Study" in inspector_label.text
and not contributor_visual.get_node("OpportunityConcernRoot").visible
),
+13
View File
@@ -53,6 +53,19 @@ func _run() -> void:
not caption.visible,
"The cinematic caption should remain hidden until the full sequence starts"
)
var derived_helper: OpportunityHelperResult = manager.get_active_opportunity_helper()
_check(
(
derived_helper != null
and derived_helper.helper_npc_id == helper.id
and derived_helper.action_id == SimulationIds.ACTION_GATHER_FOOD
and derived_helper.resource_id == SimulationIds.RESOURCE_FOOD
and derived_helper.source_id.is_empty()
and derived_helper.available_source_count > 0
and not derived_helper.uses_inventory
),
"The open need should read-only derive the staged trusted helper and a finite food route"
)
_check(demo.begin_pantry_restock(), "A trusted helper should begin the real food run")
var decision: ActionSelectionResult = manager.get_latest_decision(helper.id)
@@ -3,6 +3,7 @@ extends GutTest
const VillageOpportunitySystemScript := preload(
"res://simulation/opportunities/VillageOpportunitySystem.gd"
)
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
func test_opening_requires_current_knowledge_and_care() -> void:
@@ -192,6 +193,173 @@ func test_missing_wood_maintenance_does_not_change_the_existing_food_lifecycle()
assert_eq(food_need.get_closed_tick(), -1)
func test_capable_helper_requires_exact_knowledge_trust_and_a_real_supply_route() -> void:
var fixture := _open_fixture()
var system: VillageOpportunitySystem = fixture["system"]
var pantry: StorageStateRecord = fixture["pantry"]
var knowledge: EventKnowledgeSystem = fixture["knowledge"]
var interested: SimNPC = fixture["npcs"][0]
var helper := _npc(1, 20.0)
var npcs: Array[SimNPC] = [interested, helper]
var relationships := RelationshipSystemScript.new()
var trusted := RelationshipStateRecord.create(helper.id, interested.id, 0.8, 0.7)
relationships.restore([trusted])
var source := _resource_state(
&"orchard_berries", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 2.0, 3.0
)
var resource_states := {source.get_node_id(): source}
var opportunity: OpportunityStateRecord = system.get_open_opportunity()
assert_null(
system.find_capable_helper(
opportunity, pantry, npcs, knowledge, relationships, resource_states
)
)
_remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10)
relationships.restore([RelationshipStateRecord.create(helper.id, interested.id, 0.8, 0.59)])
assert_null(
system.find_capable_helper(
opportunity, pantry, npcs, knowledge, relationships, resource_states
)
)
relationships.restore([trusted])
var opportunity_before := opportunity.to_dictionary()
var source_before := source.to_dictionary()
var helper_inventory_before := helper.inventory.duplicate(true)
var result: OpportunityHelperResult = system.find_capable_helper(
opportunity, pantry, npcs, knowledge, relationships, resource_states
)
assert_not_null(result)
assert_eq(result.opportunity_id, opportunity.get_opportunity_id())
assert_eq(result.helper_npc_id, helper.id)
assert_eq(result.action_id, SimulationIds.ACTION_GATHER_FOOD)
assert_true(result.source_id.is_empty())
assert_eq(result.available_source_count, 1)
assert_eq(result.resource_id, SimulationIds.RESOURCE_FOOD)
assert_eq(result.trigger_event_id, opportunity.get_trigger_event_id())
assert_eq(result.trust, 0.7)
assert_true(result.profession_match)
assert_false(result.uses_inventory)
assert_string_contains(result.reason, "Knows the need")
assert_eq(opportunity.to_dictionary(), opportunity_before)
assert_eq(source.to_dictionary(), source_before)
assert_eq(helper.inventory, helper_inventory_before)
func test_ready_inventory_then_trust_and_stable_id_rank_helpers_deterministically() -> void:
var fixture := _open_fixture()
var system: VillageOpportunitySystem = fixture["system"]
var pantry: StorageStateRecord = fixture["pantry"]
var knowledge: EventKnowledgeSystem = fixture["knowledge"]
var interested: SimNPC = fixture["npcs"][0]
var trusted_gatherer := _npc(1, 20.0)
var lower_id_carrier := _npc(2, 20.0)
var stronger_carrier := _npc(5, 20.0)
lower_id_carrier.profession = SimulationIds.PROFESSION_GUARD
stronger_carrier.profession = SimulationIds.PROFESSION_GUARD
lower_id_carrier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
stronger_carrier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
var npcs: Array[SimNPC] = [interested, trusted_gatherer, lower_id_carrier, stronger_carrier]
var opportunity: OpportunityStateRecord = system.get_open_opportunity()
for helper in [trusted_gatherer, lower_id_carrier, stronger_carrier]:
_remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10)
var relationships := RelationshipSystemScript.new()
(
relationships
. restore(
[
RelationshipStateRecord.create(trusted_gatherer.id, interested.id, 0.9, 0.95),
RelationshipStateRecord.create(lower_id_carrier.id, interested.id, 0.7, 0.7),
RelationshipStateRecord.create(stronger_carrier.id, interested.id, 0.7, 0.8),
]
)
)
var source := _resource_state(
&"berry_source", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 2.0
)
var result: OpportunityHelperResult = system.find_capable_helper(
opportunity, pantry, npcs, knowledge, relationships, {source.get_node_id(): source}
)
assert_eq(result.helper_npc_id, stronger_carrier.id)
assert_eq(result.action_id, SimulationIds.ACTION_DEPOSIT_FOOD)
assert_eq(result.source_id, SimulationIds.npc_inventory_id(stronger_carrier.id))
assert_true(result.uses_inventory)
assert_eq(result.available_source_count, 0)
assert_false(result.profession_match)
assert_eq(result.trust, 0.8)
(
relationships
. restore(
[
RelationshipStateRecord.create(trusted_gatherer.id, interested.id, 0.9, 0.95),
RelationshipStateRecord.create(lower_id_carrier.id, interested.id, 0.7, 0.8),
RelationshipStateRecord.create(stronger_carrier.id, interested.id, 0.7, 0.8),
]
)
)
result = system.find_capable_helper(
opportunity, pantry, npcs, knowledge, relationships, {source.get_node_id(): source}
)
assert_eq(result.helper_npc_id, lower_id_carrier.id)
func test_wood_helper_uses_available_finite_source_and_respects_reservations() -> void:
var system := VillageOpportunitySystemScript.new()
var interested := _npc(0, 20.0)
var helper := _npc(1, 20.0)
helper.profession = SimulationIds.PROFESSION_WOODCUTTER
var npcs: Array[SimNPC] = [interested, helper]
var knowledge := EventKnowledgeSystem.new()
var opportunity := OpportunityStateRecord.create(
0,
10,
4,
interested.id,
1.0,
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD,
SimulationIds.STORAGE_VILLAGE_WOODPILE,
SimulationIds.RESOURCE_WOOD
)
system.restore([opportunity], 1)
_remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10)
var relationships := RelationshipSystemScript.new()
relationships.restore([RelationshipStateRecord.create(helper.id, interested.id, 0.7, 0.7)])
var preferred_source := _resource_state(
&"safe_grove", SimulationIds.ACTION_GATHER_WOOD, SimulationIds.RESOURCE_WOOD, 2.0, 5.0, 0.1
)
var fallback_source := _resource_state(
&"rough_grove", SimulationIds.ACTION_GATHER_WOOD, SimulationIds.RESOURCE_WOOD, 2.0, 1.0, 0.8
)
var sources := {
fallback_source.get_node_id(): fallback_source,
preferred_source.get_node_id(): preferred_source,
}
var woodpile := StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_WOODPILE, {String(SimulationIds.RESOURCE_WOOD): 0.0}
)
var result: OpportunityHelperResult = system.find_capable_helper(
opportunity, woodpile, npcs, knowledge, relationships, sources
)
assert_eq(result.helper_npc_id, helper.id)
assert_eq(result.action_id, SimulationIds.ACTION_GATHER_WOOD)
assert_true(result.source_id.is_empty())
assert_eq(result.available_source_count, 2)
assert_true(result.profession_match)
assert_true(preferred_source.reserve(99))
result = system.find_capable_helper(
opportunity, woodpile, npcs, knowledge, relationships, sources
)
assert_eq(result.available_source_count, 1)
assert_true(fallback_source.reserve(99))
assert_null(
system.find_capable_helper(opportunity, woodpile, npcs, knowledge, relationships, sources)
)
func test_restore_preserves_ids_sorted_queries_and_resolves_only_oldest_open() -> void:
var older := OpportunityStateRecord.create(2, 5, 4, 7)
var newer := OpportunityStateRecord.create(5, 8, 7, 9)
@@ -309,6 +477,27 @@ func _player_resource_states() -> Dictionary:
return result
func _resource_state(
node_id: StringName,
action_id: StringName,
resource_id: StringName,
amount: float,
discovery_priority: float = 0.0,
safety_risk: float = 0.0
) -> ResourceStateRecord:
var node := ResourceNode.new()
node.node_id = node_id
node.action_id = action_id
node.resource_id = resource_id
node.initial_amount = amount
node.yield_per_action = 1.0
node.discovery_priority = discovery_priority
node.safety_risk = safety_risk
var state := ResourceStateRecord.create_from_node(node)
node.free()
return state
func _remember(
knowledge: EventKnowledgeSystem, knower_id: int, event_id: int, acquired_tick: int
) -> void:
+65 -3
View File
@@ -64,6 +64,49 @@ func _run() -> void:
),
"The performer and nearby witness should know the exact fact while its owner retains it"
)
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
var helper_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
witness.id, actor.id
)
_check(helper_relationship != null, "Nearby household witnesses should have a directed tie")
if helper_relationship != null:
var relationship_records: Array[RelationshipStateRecord] = []
for relationship in manager.relationship_system.get_all_sorted():
if relationship == helper_relationship:
relationship_records.append(
RelationshipStateRecord.create(
witness.id, actor.id, helper_relationship.get_familiarity(), 0.7
)
)
else:
relationship_records.append(relationship)
manager.relationship_system.restore(relationship_records)
var helper_tree := _create_helper_tree()
root.add_child(helper_tree)
await process_frame
_check(
manager.register_resource_node(helper_tree), "Helper query should bind a finite NPC tree"
)
var checksum_before_helper_query: String = manager.get_state_checksum()
var helper_result: OpportunityHelperResult = manager.get_active_opportunity_helper()
_check(
(
helper_result != null
and helper_result.helper_npc_id == witness.id
and helper_result.action_id == SimulationIds.ACTION_GATHER_WOOD
and helper_result.source_id.is_empty()
and helper_result.available_source_count == 1
and helper_result.trigger_event_id == trigger_id
and helper_result.profession_match
and not helper_result.uses_inventory
and "Knows the need" in helper_result.reason
),
"The query should identify one informed trusted woodcutter with finite tree supply"
)
_check(
manager.get_state_checksum() == checksum_before_helper_query,
"Capable-helper discovery should not mutate authoritative simulation state"
)
var active_json: String = manager.serialize_state()
var active_restored := _create_manager(1202)
@@ -71,13 +114,21 @@ func _run() -> void:
active_restored.restore_state_from_json(active_json),
"An active missing-wood need should survive schema-v9 restore"
)
var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper()
_check(
active_restored.get_state_checksum() == manager.get_state_checksum(),
"Active restore should preserve the authoritative checksum"
)
_check(
(
active_restored.get_state_checksum() == manager.get_state_checksum()
and active_restored.get_active_opportunity() != null
active_restored.get_active_opportunity() != null
and active_restored.is_known_event_lasting(actor.id, trigger_id)
),
"Active restore should preserve identity, checksum, and protected evidence"
"Active restore should preserve opportunity identity and protected evidence"
)
_check(
restored_helper != null and restored_helper.helper_npc_id == witness.id,
"Active restore should re-derive the same capable helper"
)
active_restored.free()
@@ -118,6 +169,8 @@ func _run() -> void:
)
resolved_restored.free()
manager.free()
helper_tree.queue_free()
await process_frame
var player_manager := _create_manager(1204)
var player_actor := _open_missing_wood_need(player_manager)
@@ -278,6 +331,15 @@ func _create_player_tree() -> ResourceNode:
return tree
func _create_helper_tree() -> ResourceNode:
var tree := _create_player_tree()
tree.name = "HelperTree"
tree.node_id = &"helper_tree"
tree.can_player_use = false
tree.discovery_priority = 4.0
return tree
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
+15
View File
@@ -264,6 +264,7 @@ func _build_active_opportunity_display() -> String:
var interested_name := _get_npc_name(opportunity.get_interested_npc_id())
var resource_name := String(opportunity.get_resource_id()).capitalize()
var current_amount := _get_opportunity_current_amount(opportunity)
var helper_text := _build_opportunity_helper_display()
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
var action_name := _get_opportunity_action_name(opportunity)
return (
@@ -271,12 +272,26 @@ func _build_active_opportunity_display() -> String:
+ "◆ Supply wood for blocked work\n"
+ "%s could not finish %s\n" % [interested_name, action_name]
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
+ helper_text
)
return (
"\n\nVillage need\n"
+ "◆ Restock the empty pantry\n"
+ "%s is worried about the shortage\n" % interested_name
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
+ helper_text
)
func _build_opportunity_helper_display() -> String:
if not simulation_manager.has_method("get_active_opportunity_helper"):
return ""
var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
if helper == null:
return "\nPossible helper: none informed, trusted, and able to supply"
return (
"\nPossible helper: %s%s\n%s"
% [_get_npc_name(helper.helper_npc_id), _get_action_name(helper.action_id), helper.reason]
)