feat(simulation): D-252 — Facing is view-only; NPC gaze moves to path-follow intent (T-1093)

apply_move no longer writes Facing (was the only movement-facing coupling,
player-only); the player's view changes solely via explicit SetFacing.
NPC path-follow now sets Facing to the step direction — a strict improvement
recorded as a D-252 correction: NPCs previously never received Facing from
movement, their cones sat at spawn direction while walking. Bump-to-turn
retired (blocked moves change nothing) with a regression test for each
semantic. Wire schema unchanged; player_facing docs now say view/aim.
Gauntlet fixtures regenerated (facing octants now reflect view-only
semantics); client replay + live-roundtrip suites green against the new
server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 21:07:18 +02:00
co-authored by Claude Fable 5
parent 629c0a9b1e
commit 0c91ef28ba
11 changed files with 2317 additions and 349 deletions
+96 -5
View File
@@ -11,7 +11,6 @@ use std::collections::BTreeMap;
use crate::bridge::types::MovementStance;
use crate::knowledge::types::SoundRange;
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind};
use crate::simulation::stance::Stance;
@@ -476,10 +475,9 @@ pub fn apply_move(
commands.entity(entity).insert(MoveIntent {
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
});
// Update facing direction based on movement (D-015 vision cone)
commands
.entity(entity)
.insert(Facing(facing_from_delta(dx, dy)));
// Facing is view-only (D-252): accepted moves no longer overwrite it. The
// player's Facing changes only via the explicit SetFacing input path
// (perception::vision_cone::handle_set_facing).
}
#[cfg(test)]
@@ -1454,4 +1452,97 @@ mod tests {
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some());
}
// -----------------------------------------------------------------------
// Facing is view-only (D-252): movement never writes Facing
// -----------------------------------------------------------------------
#[test]
fn accepted_move_does_not_change_explicit_facing() {
use crate::bridge::types::FacingDirection;
use crate::perception::vision_cone::Facing;
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Player aims east via an explicit SetFacing (represented directly here).
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Facing(FacingDirection::East),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
// The move is accepted…
assert!(
world.get::<MoveIntent>(player).is_some(),
"move should be accepted"
);
// …but the explicit aim is untouched — Facing is view-only (D-252).
assert_eq!(
world.get::<Facing>(player).unwrap().0,
FacingDirection::East,
"accepted move must not overwrite explicit Facing (D-252)"
);
}
#[test]
fn blocked_move_does_not_change_facing() {
// "Bump-to-turn" is retired (D-252): a move blocked by terrain leaves
// both position and Facing unchanged.
use crate::bridge::types::FacingDirection;
use crate::perception::vision_cone::Facing;
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Wall the tile directly north of the player (y-down: north = y-1).
let mut map = WalkabilityMap::new(10, 10, 1);
map.set_walkable(&TilePosition::new(5, 4, 0), false);
world.insert_resource(map);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Facing(FacingDirection::East),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((process_player_input, validate_movement).chain());
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
// Position held by the wall…
assert_eq!(
*world.get::<TilePosition>(player).unwrap(),
TilePosition::new(5, 5, 0),
"blocked move should not change position"
);
// …and Facing unchanged — no bump-to-turn (D-252).
assert_eq!(
world.get::<Facing>(player).unwrap().0,
FacingDirection::East,
"blocked move must not change Facing (D-252)"
);
}
}