feat(simulation): Sprint 6 Touch — stance, tile presence, verbs, protocol v6

Implements the core Sprint 6: Touch systems across 5 tickets:

- #449 ObserverSnapshot v6: add player_stance (MovementStance) and
  player_inventory (Vec<InventoryItem>) wire fields with serde defaults
  for backward compatibility. Bump PROTOCOL_VERSION 5→6.

- #417 Stance system: Sprint/Walk/Careful/Crouch movement stance with
  tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers,
  and PlayerMoveCooldown component. ToggleStanceUp/Down player actions.

- #420 TilePresence: posture-layer collision system allowing same-tile
  occupancy for different layers (Standing/Prone/Seated/Fixture).
  Layer-based collision in validate_movement.

- #421 ObjectType component: Readable/Container/Terminal/Door/Pickup/
  Furniture types with Phase 1 verb sets computed from type + proximity.

- #422 Phase 2 verb filter: KG-gated observer-side verb processing —
  POI priority flips, Confront injection at KnowsDetails+, contradiction
  marking, archetype-specific label relabeling (Smuggler/Detective).

217 unit tests + 17 integration tests passing. All MessagePack fixtures
regenerated for v6 wire format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 15:38:59 +01:00
co-authored by Claude Opus 4.6
parent 651b1d34a6
commit 98f4cedc03
18 changed files with 1825 additions and 95 deletions
+307 -20
View File
@@ -1,5 +1,6 @@
// Tile-based movement and collision system
// Implements Sprint 1 ticket #236: walkability map and movement validation
// Extended by #420: TilePresence posture layers for same-tile occupancy (D-054)
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
// Y-down convention: North = y-1, South = y+1
@@ -14,6 +15,27 @@ pub const CHUNK_SIZE: i32 = 32;
#[derive(Component, Debug)]
pub struct PlayerCharacter;
/// Posture layer for same-tile occupancy (D-054, #420).
///
/// Multiple entities can share a tile if they occupy different posture layers.
/// Two entities in the same layer on the same tile is a collision.
///
/// Examples: a Standing character can walk past a Seated NPC at a console,
/// a Fixture (terminal) shares a tile with someone Seated at it.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum TilePresence {
/// Upright position — walking, standing, sprinting. Default for all entities.
#[default]
Standing,
/// Low position — crouching or prone on the ground.
Prone,
/// Seated at furniture, console, or vehicle.
Seated,
/// Immovable world fixture — terminals, furniture, consoles.
/// Occupies its layer permanently.
Fixture,
}
/// Tile position component for grid-based movement.
/// Discrete integer coordinates used in simulation; converted to f32
/// at the bridge boundary for VisibleEntity wire format.
@@ -226,48 +248,59 @@ pub struct MoveIntent {
}
/// System to validate and execute movement intents.
/// Checks walkability map AND entity-entity collision before allowing moves.
/// Processes all intents in a single pass: first collect occupied tiles from
/// Checks walkability map AND layer-based entity collision before allowing moves.
///
/// Same-tile occupancy (D-054, #420): multiple entities can share a tile if they
/// occupy different posture layers (TilePresence). Two entities in the same layer
/// on the same tile is a collision. Entities without TilePresence default to Standing.
///
/// Processes all intents in a single pass: first collect occupied layer slots from
/// entities without intents, then resolve movers in order — first valid claim
/// to a tile wins.
/// to a layer slot wins.
/// Always removes MoveIntent component after processing.
pub fn validate_movement(
mut commands: Commands,
walkability: Option<Res<WalkabilityMap>>,
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>,
stationary: Query<(Entity, &TilePosition), Without<MoveIntent>>,
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>)>,
stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without<MoveIntent>>,
) {
let Some(map) = walkability else {
tracing::warn!("No WalkabilityMap loaded — rejecting all move intents");
for (entity, _, _) in movers.iter() {
for (entity, _, _, _) in movers.iter() {
commands.entity(entity).remove::<MoveIntent>();
}
return;
};
// Collect tiles occupied by stationary entities (no MoveIntent)
let mut occupied: HashMap<TilePosition, Entity> = HashMap::new();
for (entity, pos) in stationary.iter() {
occupied.insert(*pos, entity);
// Collect layer slots occupied by stationary entities (no MoveIntent).
// Key: (position, layer) — two entities can share a tile if different layers.
let mut occupied: HashMap<(TilePosition, TilePresence), Entity> = HashMap::new();
for (entity, pos, presence) in stationary.iter() {
let layer = presence.copied().unwrap_or_default();
occupied.insert((*pos, layer), entity);
}
for (entity, intent, mut position) in movers.iter_mut() {
for (entity, intent, mut position, presence) in movers.iter_mut() {
let target = &intent.target;
let layer = presence.copied().unwrap_or_default();
let slot = (*target, layer);
if !map.can_move_to(target) {
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
} else if occupied.contains_key(target) {
tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target);
} else if occupied.contains_key(&slot) {
tracing::trace!(
"Entity {:?} blocked by entity at {:?} (layer {:?})",
entity, target, layer
);
} else {
tracing::trace!(
"Entity {:?} moving from {:?} to {:?}",
entity,
*position,
target
"Entity {:?} moving from {:?} to {:?} (layer {:?})",
entity, *position, target, layer
);
// Free old tile, claim new tile
occupied.remove(&*position);
// Free old layer slot, claim new one
occupied.remove(&(*position, layer));
*position = *target;
occupied.insert(*target, entity);
occupied.insert(slot, entity);
}
commands.entity(entity).remove::<MoveIntent>();
}
@@ -570,4 +603,258 @@ mod tests {
);
assert!(world.get::<MoveIntent>(entity).is_none());
}
// -----------------------------------------------------------------------
// TilePresence / same-tile occupancy tests (D-054, #420)
// -----------------------------------------------------------------------
#[test]
fn tile_presence_default_is_standing() {
assert_eq!(TilePresence::default(), TilePresence::Standing);
}
#[test]
fn same_layer_same_tile_blocks_movement() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary entity at target, Standing layer
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Mover also Standing — should be blocked
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"same-layer collision should block movement"
);
}
#[test]
fn different_layer_same_tile_allows_movement() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Fixture at target tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Standing mover — different layer, should pass
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 4, 0),
"different layers should share a tile"
);
}
#[test]
fn seated_and_fixture_share_tile() {
// Common case: NPC seated at a terminal (Fixture)
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Terminal fixture at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Seated NPC moves to same tile
let npc = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Seated,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(npc).unwrap(),
TilePosition::new(5, 4, 0),
"Seated NPC should share tile with Fixture"
);
}
#[test]
fn prone_and_standing_share_tile() {
// Eavesdrop scenario: prone entity next to standing entity
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Standing NPC at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Prone entity moves in — different layer
let prone = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Prone,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(prone).unwrap(),
TilePosition::new(5, 4, 0),
"Prone should share tile with Standing"
);
}
#[test]
fn entity_without_tile_presence_defaults_to_standing() {
// Backwards compat: entities spawned without TilePresence should
// still collide with Standing entities (default layer).
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary entity WITHOUT TilePresence component
world.spawn(TilePosition::new(5, 4, 0));
// Mover also WITHOUT TilePresence — both default to Standing
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"entities without TilePresence should default to Standing and collide"
);
}
#[test]
fn entity_without_presence_blocked_by_standing() {
// Entity without TilePresence blocked by explicit Standing entity
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary with explicit Standing
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Mover without TilePresence (defaults to Standing)
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"no-presence entity should collide with Standing"
);
}
#[test]
fn three_layers_on_same_tile() {
// Maximum plausible scenario: Standing + Seated + Fixture on one tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Fixture already at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Seated already at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Seated));
// Standing mover enters — third layer
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 4, 0),
"three different layers should coexist on one tile"
);
}
#[test]
fn two_fixtures_same_tile_blocked() {
// Edge case: two fixtures can't stack on the same tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Fixture,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"two Fixtures on same tile should collide"
);
}
}