feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms
Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion Corridor, Pause Chamber) + Central Hub, room constants module, room reset trigger mechanism, Layer 3 subprocess integration test, golden file comparison engine and test suite, and content runtime validation. Tickets: #482, #484, #485, #487, #488, #489, #490 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ use crate::simulation::inventory::{
|
||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
|
||||
use crate::simulation::time::{SimulationTime, TickRate};
|
||||
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
@@ -91,6 +92,8 @@ pub fn process_player_input(
|
||||
>,
|
||||
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
all_positions: Query<&TilePosition>,
|
||||
reset_triggers: Query<&RoomResetTrigger>,
|
||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
let paused = time.paused();
|
||||
@@ -193,6 +196,16 @@ pub fn process_player_input(
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Reset") => {
|
||||
handle_reset(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&reset_triggers,
|
||||
&mut room_snapshots,
|
||||
target_entity_id,
|
||||
current_tick,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only",
|
||||
@@ -438,6 +451,67 @@ fn handle_place(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Reset verb: restore a room's entities to their initial positions.
|
||||
/// Target entity must have a RoomResetTrigger component. Respects debounce.
|
||||
fn handle_reset(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
reset_triggers: &Query<&RoomResetTrigger>,
|
||||
room_snapshots: &mut Option<ResMut<RoomSnapshots>>,
|
||||
target_entity_id: Option<u64>,
|
||||
current_tick: u64,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Reset verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(snapshots) = room_snapshots.as_mut() else {
|
||||
tracing::warn!("Reset verb but RoomSnapshots resource not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Reset: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(trigger) = reset_triggers.get(target_entity) else {
|
||||
tracing::warn!(target_id, "Reset: target is not a reset trigger");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else {
|
||||
tracing::info!(
|
||||
room = trigger.room_name.as_str(),
|
||||
"Reset: debounced or unknown room"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut restored = 0;
|
||||
for (entity, position, is_floor_item) in changes {
|
||||
if is_floor_item {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<CarriedBy>()
|
||||
.remove::<InventorySlot>()
|
||||
.insert(position);
|
||||
} else {
|
||||
commands.entity(entity).insert(position);
|
||||
}
|
||||
restored += 1;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
room = trigger.room_name.as_str(),
|
||||
restored,
|
||||
current_tick,
|
||||
"Room reset executed via Reset verb"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1396,4 +1470,224 @@ mod tests {
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// === Room Reset Tests (#490) ===
|
||||
|
||||
#[test]
|
||||
fn reset_verb_restores_floor_item() {
|
||||
// #490: Take a floor item, then Reset verb restores it to original position.
|
||||
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
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(player);
|
||||
|
||||
// Floor item at (5, 4)
|
||||
let item = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into())))
|
||||
.id();
|
||||
let item_sid = world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(item);
|
||||
|
||||
// Reset plate entity
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(plate);
|
||||
|
||||
// Record snapshot: item is a floor item at its original position
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true);
|
||||
world.insert_resource(snapshots);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Step 1: Take the item
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"Item should be picked up"
|
||||
);
|
||||
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
|
||||
|
||||
// Step 2: Reset via verb
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 1;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Item should be back on the ground at original position
|
||||
let pos = world
|
||||
.get::<TilePosition>(item)
|
||||
.expect("Item should be restored to ground");
|
||||
assert_eq!(
|
||||
*pos,
|
||||
TilePosition::new(5, 4, 0),
|
||||
"Item at original position"
|
||||
);
|
||||
assert!(
|
||||
world.get::<CarriedBy>(item).is_none(),
|
||||
"CarriedBy removed after reset"
|
||||
);
|
||||
assert!(
|
||||
world.get::<InventorySlot>(item).is_none(),
|
||||
"InventorySlot removed after reset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_verb_debounces() {
|
||||
// #490: Reset debounce prevents rapid-fire resets.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let _player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
// NPC entity
|
||||
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
||||
world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(npc);
|
||||
|
||||
// Reset plate
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(plate);
|
||||
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false);
|
||||
world.insert_resource(snapshots);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// First reset at tick 0 — should succeed
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Move NPC to verify debounce blocks second reset
|
||||
*world.get_mut::<TilePosition>(npc).unwrap() = TilePosition::new(20, 20, 0);
|
||||
|
||||
// Second reset at tick 5 — should be debounced (< 10 ticks)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC should still be at moved position (reset was debounced)
|
||||
assert_eq!(
|
||||
world.get::<TilePosition>(npc).unwrap().x,
|
||||
20,
|
||||
"NPC not reset — debounced"
|
||||
);
|
||||
|
||||
// Third reset at tick 10 — should succeed
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 10,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC should be back at original position
|
||||
assert_eq!(
|
||||
world.get::<TilePosition>(npc).unwrap().x,
|
||||
10,
|
||||
"NPC reset after debounce elapsed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_verb_without_snapshots_is_noop() {
|
||||
// Reset verb when no RoomSnapshots resource exists should not panic.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(plate);
|
||||
|
||||
// No RoomSnapshots resource inserted — should be gracefully handled
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user