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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user