Skip to content

Commit b0bee65

Browse files
milkyskiesclaude
andcommitted
[#739] Tile-trait beliefs out of MindGraph
Grazable and Drinkable are objective world facts derivable from terrain type, not subjective beliefs. The audit on issue #739 showed perception churned 717K HasTrait mutations on a 12-game-hour run, dominated by the "agent walks in → asserts (Tile, HasTrait, Grazable) → walks out → removes it" cycle, with N agents perceiving the same tile producing N duplicate triples. Replace the per-agent belief path with a direct WorldMap spatial query: - Add TileType::has_trait(concept) and WorldMap::tiles_with_trait(...) that map terrain types to concepts (Grass→Grazable, water→Drinkable). - target_enumeration::enumerate_tiles_with_trait now scans the world map within a planning radius of the agent — no MindGraph round-trip. - Delete perceive_grass_tiles and perceive_water_tiles entirely. No legacy shim per the no-backwards-compat rule. Subjective tile beliefs that are *not* derivable from terrain stay in MindGraph: Unreachable (per-agent learned pathing failure with TTL), Territory (per-agent claim, set once at spawn). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04954f5 commit b0bee65

6 files changed

Lines changed: 226 additions & 199 deletions

File tree

src/agent/brains/rational.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ pub fn update_rational_planning(
265265
>,
266266
tick: Res<crate::core::tick::TickCount>,
267267
ns_config: Res<crate::agent::nervous_system::config::NervousSystemConfig>,
268-
_world_map: Res<WorldMap>,
268+
world_map: Res<WorldMap>,
269269
action_registry: Res<crate::agent::actions::ActionRegistry>,
270270
mut game_log: ResMut<crate::core::GameLog>,
271271
affordances: Query<(
@@ -610,6 +610,8 @@ pub fn update_rational_planning(
610610
let action_candidates = collect_planning_actions(
611611
&action_registry,
612612
mind,
613+
transform.translation.truncate(),
614+
&world_map,
613615
&affordances,
614616
PlanningMode::Generate,
615617
&capacities,
@@ -940,6 +942,8 @@ impl TargetInclusionReason {
940942
fn collect_planning_actions(
941943
action_registry: &crate::agent::actions::ActionRegistry,
942944
mind: &MindGraph,
945+
agent_pos: Vec2,
946+
world_map: &WorldMap,
943947
affordances: &Query<(
944948
&GlobalTransform,
945949
Option<&crate::agent::affordance::Affordance>,
@@ -964,7 +968,14 @@ fn collect_planning_actions(
964968
}
965969

966970
let source = action.target_source();
967-
for candidate in enumerate_targets(&source, action.action_type(), mind, affordances) {
971+
for candidate in enumerate_targets(
972+
&source,
973+
action.action_type(),
974+
mind,
975+
agent_pos,
976+
world_map,
977+
affordances,
978+
) {
968979
let reason = match mode {
969980
PlanningMode::Generate => {
970981
if action.is_plan_valid(&candidate, mind) {

src/agent/brains/target_enumeration.rs

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Generic target enumeration: turn a `TargetSource` into concrete `TargetCandidate`s.
22
//!
3-
//! Reads: MindGraph (Contains/HasTrait beliefs), Affordance components on world entities
3+
//! Reads: MindGraph (Contains/IsA beliefs), WorldMap (terrain-trait spatial queries),
4+
//! Affordance components on world entities
45
//! Writes: Vec<TargetCandidate> for the rational brain
56
//! Upstream: rational brain (asks "what targets does action X have?")
67
//! Downstream: rational brain (turns candidates into ActionTemplates)
@@ -17,7 +18,14 @@ use crate::agent::Dead;
1718
use crate::agent::actions::{ActionType, TargetCandidate, TargetSource};
1819
use crate::agent::affordance::Affordance;
1920
use crate::agent::mind::knowledge::{Concept, MindGraph, Node, Predicate, Value};
20-
use crate::world::map::TILE_SIZE;
21+
use crate::world::map::WorldMap;
22+
23+
/// Spatial scope for `TargetSource::TileWithTrait`: the planner walks all
24+
/// tiles within this radius of the agent and keeps the ones whose terrain
25+
/// type matches the requested trait. Sized to comfortably cover an agent's
26+
/// vision range plus a planning buffer so a thirsty deer doesn't keep
27+
/// missing the river one tile past sight.
28+
const TILE_TRAIT_SEARCH_RADIUS: f32 = 256.0;
2129

2230
/// Resolve a `TargetSource` into the concrete list of candidates for a given
2331
/// action and agent mind.
@@ -28,8 +36,8 @@ use crate::world::map::TILE_SIZE;
2836
/// declares `action_type`
2937
/// - `TargetSource::EntityWithTrait(c)` → every perceived entity whose
3038
/// inherited traits include `c` (e.g. Attack/Bite finding `HasTrait Prey`)
31-
/// - `TargetSource::TileWithTrait(c)` → every known tile matching
32-
/// `Tile(?) HasTrait c` in the MindGraph
39+
/// - `TargetSource::TileWithTrait(c)` → every nearby tile whose terrain type
40+
/// satisfies `c` per [`crate::world::map::TileType::has_trait`]
3341
///
3442
/// The `affordances` query is a bare `&Query<...>` so Bevy's elided
3543
/// WorldQuery lifetimes flow through to callers without a type alias
@@ -38,6 +46,8 @@ pub fn enumerate_targets(
3846
source: &TargetSource,
3947
action_type: ActionType,
4048
mind: &MindGraph,
49+
agent_pos: Vec2,
50+
world_map: &WorldMap,
4151
affordances: &Query<(&GlobalTransform, Option<&Affordance>, Option<&Dead>)>,
4252
) -> Vec<TargetCandidate> {
4353
match source {
@@ -52,7 +62,9 @@ pub fn enumerate_targets(
5262
TargetSource::DeadEntityWithTrait(concept) => {
5363
enumerate_entities_with_trait(*concept, mind, affordances, AliveOnly::No)
5464
}
55-
TargetSource::TileWithTrait(concept) => enumerate_tiles_with_trait(*concept, mind),
65+
TargetSource::TileWithTrait(concept) => {
66+
enumerate_tiles_with_trait(*concept, agent_pos, world_map)
67+
}
5668
TargetSource::EntityIsAConcept(concept) => {
5769
enumerate_entities_isa_concept(*concept, mind, affordances)
5870
}
@@ -222,35 +234,21 @@ fn candidate_is_lame(candidate: &TargetCandidate, mind: &MindGraph) -> bool {
222234
mind.has_trait(&Node::Entity(*entity), Concept::Lame)
223235
}
224236

225-
/// Iterate tiles matching `Tile(?) HasTrait <concept>` in the MindGraph and
226-
/// emit one candidate per known tile. The world position is the tile centre
227-
/// so the planner's distance heuristic and the implicit Walk generator both
228-
/// have something to chew on.
237+
/// Iterate tiles within [`TILE_TRAIT_SEARCH_RADIUS`] of `agent_pos` whose
238+
/// terrain type satisfies `concept` and emit one candidate per match.
229239
///
230-
/// Drink uses this with `Concept::Drinkable`. Future tile-trait actions
231-
/// (Fish, Forage, Bathe, Sleep-in-shelter) plug in here without touching
232-
/// the brain.
233-
fn enumerate_tiles_with_trait(concept: Concept, mind: &MindGraph) -> Vec<TargetCandidate> {
234-
let mut candidates = Vec::new();
235-
236-
for triple in mind.query(
237-
None,
238-
Some(Predicate::HasTrait),
239-
Some(&Value::Concept(concept)),
240-
) {
241-
let Node::Tile((tx, ty)) = triple.subject else {
242-
continue;
243-
};
244-
245-
let pos = Vec2::new(
246-
tx as f32 * TILE_SIZE + TILE_SIZE / 2.0,
247-
ty as f32 * TILE_SIZE + TILE_SIZE / 2.0,
248-
);
249-
candidates.push(TargetCandidate::Tile {
250-
tile: (tx, ty),
251-
pos,
252-
});
253-
}
254-
255-
candidates
240+
/// Drink/Fish use this with `Concept::Drinkable`, Graze with `Grazable`.
241+
/// The query reads the world map directly: terrain traits are objective
242+
/// facts (grass is grazable, water is drinkable), so we don't run them
243+
/// through the agent's subjective belief store.
244+
fn enumerate_tiles_with_trait(
245+
concept: Concept,
246+
agent_pos: Vec2,
247+
world_map: &WorldMap,
248+
) -> Vec<TargetCandidate> {
249+
world_map
250+
.tiles_with_trait(agent_pos, TILE_TRAIT_SEARCH_RADIUS, concept)
251+
.into_iter()
252+
.map(|(tile, pos)| TargetCandidate::Tile { tile, pos })
253+
.collect()
256254
}

src/agent/mind/perception.rs

Lines changed: 0 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -456,130 +456,6 @@ fn perceive_inventory(
456456
}
457457
}
458458

459-
// ═══════════════════════════════════════════════════════════════════════════
460-
// WATER PERCEPTION — Detect water tiles in vision range
461-
// ═══════════════════════════════════════════════════════════════════════════
462-
463-
/// Water tiles are static terrain — scan infrequently (every 30 ticks per agent).
464-
pub fn perceive_water_tiles(
465-
mut agents: Query<(Entity, &Transform, &Vision, &mut MindGraph), With<Agent>>,
466-
world_map: Res<crate::world::map::WorldMap>,
467-
light_level: Res<LightLevel>,
468-
tick: Res<TickCount>,
469-
) {
470-
let current_time = tick.current;
471-
472-
for (entity, transform, vision, mut mind) in agents.iter_mut() {
473-
if !tick.should_run(entity, 30) {
474-
continue;
475-
}
476-
477-
let pos = transform.translation.truncate();
478-
let view_range = vision.range * light_level.0;
479-
let tile_range = (view_range / TILE_SIZE).ceil() as i32;
480-
481-
let center_tx = (pos.x / TILE_SIZE).floor() as i32;
482-
let center_ty = (pos.y / TILE_SIZE).floor() as i32;
483-
484-
for dx in -tile_range..=tile_range {
485-
for dy in -tile_range..=tile_range {
486-
let tx = center_tx + dx;
487-
let ty = center_ty + dy;
488-
if tx < 0 || ty < 0 {
489-
continue;
490-
}
491-
492-
let tile_world = world_map.tile_to_world(tx, ty);
493-
if pos.distance(tile_world) > view_range {
494-
continue;
495-
}
496-
497-
if let Some(tile_type) = world_map.get_tile(tx as u32, ty as u32)
498-
&& tile_type.is_water()
499-
{
500-
mind.assert(Triple::with_meta(
501-
Node::Tile((tx, ty)),
502-
Predicate::HasTrait,
503-
Value::Concept(Concept::Drinkable),
504-
Metadata::semantic(current_time),
505-
));
506-
}
507-
}
508-
}
509-
}
510-
}
511-
512-
// ═══════════════════════════════════════════════════════════════════════════
513-
// GRASS PERCEPTION — Detect grazable tiles for herbivores
514-
// ═══════════════════════════════════════════════════════════════════════════
515-
516-
/// Grass tiles are static terrain — scan infrequently (every 30 ticks per agent).
517-
///
518-
/// Gated to herbivores so the planner only considers grazing for species that
519-
/// would actually do it. Carnivores and omnivores never get `HasTrait Grazable`
520-
/// asserted, keeping their MindGraph free of useless noise and their rational
521-
/// brain from enumerating grass tiles as food candidates.
522-
pub fn perceive_grass_tiles(
523-
mut agents: Query<
524-
(
525-
Entity,
526-
&Transform,
527-
&Vision,
528-
&crate::agent::body::species::SpeciesProfile,
529-
&mut MindGraph,
530-
),
531-
With<Agent>,
532-
>,
533-
world_map: Res<crate::world::map::WorldMap>,
534-
light_level: Res<LightLevel>,
535-
tick: Res<TickCount>,
536-
) {
537-
use crate::agent::body::species::Diet;
538-
use crate::world::map::TileType;
539-
540-
let current_time = tick.current;
541-
542-
for (entity, transform, vision, species, mut mind) in agents.iter_mut() {
543-
if !matches!(species.diet, Diet::Herbivore) {
544-
continue;
545-
}
546-
if !tick.should_run(entity, 30) {
547-
continue;
548-
}
549-
550-
let pos = transform.translation.truncate();
551-
let view_range = vision.range * light_level.0;
552-
let tile_range = (view_range / TILE_SIZE).ceil() as i32;
553-
554-
let center_tx = (pos.x / TILE_SIZE).floor() as i32;
555-
let center_ty = (pos.y / TILE_SIZE).floor() as i32;
556-
557-
for dx in -tile_range..=tile_range {
558-
for dy in -tile_range..=tile_range {
559-
let tx = center_tx + dx;
560-
let ty = center_ty + dy;
561-
if tx < 0 || ty < 0 {
562-
continue;
563-
}
564-
565-
let tile_world = world_map.tile_to_world(tx, ty);
566-
if pos.distance(tile_world) > view_range {
567-
continue;
568-
}
569-
570-
if let Some(TileType::Grass) = world_map.get_tile(tx as u32, ty as u32) {
571-
mind.assert(Triple::with_meta(
572-
Node::Tile((tx, ty)),
573-
Predicate::HasTrait,
574-
Value::Concept(Concept::Grazable),
575-
Metadata::semantic(current_time),
576-
));
577-
}
578-
}
579-
}
580-
}
581-
}
582-
583459
// ═══════════════════════════════════════════════════════════════════════════
584460
// DANGER PERCEPTION — Contextual threat assessment produces Fear
585461
// ═══════════════════════════════════════════════════════════════════════════

src/agent/mod.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,6 @@ impl Plugin for AgentPlugin {
185185
FixedUpdate,
186186
(
187187
mind::perception::update_body_perception,
188-
mind::perception::perceive_water_tiles,
189-
mind::perception::perceive_grass_tiles,
190188
mind::perception::perceive_temperature,
191189
mind::perception::perceive_hearing,
192190
mind::perception::emit_alarm_calls,

0 commit comments

Comments
 (0)