Merge remote-tracking branch 'origin/server'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
server/target/
|
||||
tooling/content-converter/target/
|
||||
tooling/line-previewer/target/
|
||||
tooling/test-client/target/
|
||||
content-ron/
|
||||
|
||||
# Godot client (further ignores managed by client team)
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader
|
||||
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
|
||||
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
|
||||
|
||||
### Added
|
||||
- Weapon aim lock audio (`sfx_weapon_aim_lock.ogg`) — clinical targeting confirmation tone for weapon aim state (#440)
|
||||
- Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440)
|
||||
- `make pre-pr` target — full pre-PR verification chain: lint → build → test → content validation → fixture staleness (#460, #465)
|
||||
|
||||
@@ -9,6 +9,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
pub mod framing;
|
||||
pub mod local;
|
||||
pub mod tcp;
|
||||
pub mod text_renderer;
|
||||
pub mod types;
|
||||
pub use types::*;
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@ impl TcpBridge {
|
||||
|
||||
// Set non-blocking so receive_inputs doesn't stall the game loop.
|
||||
// read_framed handles WouldBlock by returning Ok(None).
|
||||
stream.set_nonblocking(true).map_err(|e| {
|
||||
BridgeError::Transport(format!("failed to set non-blocking: {}", e))
|
||||
})?;
|
||||
stream
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
|
||||
|
||||
// Clone stream for reader and writer
|
||||
let reader_stream = stream.try_clone().map_err(|e| {
|
||||
@@ -81,9 +81,9 @@ impl TcpBridge {
|
||||
local_addr
|
||||
);
|
||||
|
||||
stream.set_nonblocking(true).map_err(|e| {
|
||||
BridgeError::Transport(format!("failed to set non-blocking: {}", e))
|
||||
})?;
|
||||
stream
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
|
||||
|
||||
let reader_stream = stream.try_clone().map_err(|e| {
|
||||
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
// Text renderer for ObserverSnapshot — structured human-readable output.
|
||||
// Library code callable by test-client crate and server integration tests.
|
||||
// The server binary never references this module.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::types::{EntityVisibility, RelationshipState};
|
||||
|
||||
/// Format an ObserverSnapshot as structured text for human verification.
|
||||
///
|
||||
/// Output format matches the test-client --text specification:
|
||||
/// header, game time, room, entities (sorted by distance), pending
|
||||
/// recognitions, tiles, interactions, inventory, monologue, dialogue.
|
||||
pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
|
||||
let mut out = String::with_capacity(2048);
|
||||
|
||||
// Find player entity for position reference
|
||||
let player = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Player));
|
||||
let (px, py) = player.map(|p| (p.x as i32, p.y as i32)).unwrap_or((-1, -1));
|
||||
|
||||
// Header
|
||||
writeln!(
|
||||
out,
|
||||
"=== Tick {} | Player ({},{}) facing {:?} | Stance: {:?} | TickRate: {:?} ===",
|
||||
snapshot.tick,
|
||||
px,
|
||||
py,
|
||||
snapshot.player_facing,
|
||||
snapshot.player_stance,
|
||||
snapshot.game_time.tick_rate,
|
||||
)
|
||||
.ok();
|
||||
|
||||
// Game time
|
||||
let hours = (snapshot.game_time.time_of_day / 60) % 24;
|
||||
let minutes = snapshot.game_time.time_of_day % 60;
|
||||
writeln!(
|
||||
out,
|
||||
"Game time: Day {}, {:02}:{:02} ({:?})",
|
||||
snapshot.game_time.day, hours, minutes, snapshot.game_time.day_phase,
|
||||
)
|
||||
.ok();
|
||||
|
||||
// Room name — stub until Gauntlet room constants are implemented (Sprint 9)
|
||||
writeln!(out, "Room: (unknown)").ok();
|
||||
|
||||
// Non-player entities sorted by distance then entity_id
|
||||
let mut entities: Vec<&VisibleEntity> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| !matches!(e.kind, EntityKind::Player))
|
||||
.collect();
|
||||
entities.sort_by_key(|e| {
|
||||
let dist = (e.x as i32 - px).unsigned_abs() + (e.y as i32 - py).unsigned_abs();
|
||||
(dist, e.entity_id)
|
||||
});
|
||||
|
||||
if !entities.is_empty() {
|
||||
writeln!(out, "Entities ({}):", entities.len()).ok();
|
||||
for e in &entities {
|
||||
let dist = (e.x as i32 - px).unsigned_abs() + (e.y as i32 - py).unsigned_abs();
|
||||
writeln!(
|
||||
out,
|
||||
" {}:{:<8} ({},{}) {:<8} rel:{:<16} vis:{:<12} d={}",
|
||||
kind_label(e.kind),
|
||||
e.entity_id,
|
||||
e.x as i32,
|
||||
e.y as i32,
|
||||
sector_label(e.visibility),
|
||||
relationship_label(e.relationship),
|
||||
observation_label(&e.observation),
|
||||
dist,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Pending recognitions
|
||||
if !snapshot.pending_recognitions.is_empty() {
|
||||
write!(
|
||||
out,
|
||||
"Pending recognitions: {}",
|
||||
snapshot.pending_recognitions.len()
|
||||
)
|
||||
.ok();
|
||||
for pr in &snapshot.pending_recognitions {
|
||||
let elapsed = pr.total_delay_ticks - pr.remaining_ticks;
|
||||
write!(
|
||||
out,
|
||||
" [npc:{} at ({},{}) {}/{} ticks]",
|
||||
pr.entity_id, pr.x as i32, pr.y as i32, elapsed, pr.total_delay_ticks,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
writeln!(out).ok();
|
||||
}
|
||||
|
||||
// Tiles
|
||||
writeln!(out, "Tiles: {} visible", snapshot.visible_tiles.len()).ok();
|
||||
|
||||
// Interactions
|
||||
if !snapshot.nearby_interactions.is_empty() {
|
||||
writeln!(
|
||||
out,
|
||||
"Interactions ({}):",
|
||||
snapshot.nearby_interactions.len()
|
||||
)
|
||||
.ok();
|
||||
for ni in &snapshot.nearby_interactions {
|
||||
let verbs: Vec<String> = ni
|
||||
.verbs
|
||||
.iter()
|
||||
.map(|v| format!("{}({})", v.label, v.priority))
|
||||
.collect();
|
||||
writeln!(
|
||||
out,
|
||||
" {}:{} [{}] distance={}",
|
||||
kind_label(ni.entity_type),
|
||||
ni.entity_id,
|
||||
verbs.join(", "),
|
||||
ni.distance,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Inventory
|
||||
if !snapshot.player_inventory.is_empty() {
|
||||
let slots: Vec<String> = snapshot
|
||||
.player_inventory
|
||||
.iter()
|
||||
.map(|item| format!("item:{}(slot-{})", item.item_id, item.slot))
|
||||
.collect();
|
||||
writeln!(
|
||||
out,
|
||||
"Inventory: {}/9 [{}]",
|
||||
snapshot.player_inventory.len(),
|
||||
slots.join(", "),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Monologue
|
||||
if let Some(ref mono) = snapshot.current_monologue {
|
||||
writeln!(out, "Monologue: \"{}\"", mono.text).ok();
|
||||
}
|
||||
|
||||
// Dialogue response
|
||||
if let Some(ref dialogue) = snapshot.dialogue_response {
|
||||
writeln!(
|
||||
out,
|
||||
"Dialogue: [npc:{}] \"{}\"",
|
||||
dialogue.speaker_entity_id, dialogue.text
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
writeln!(out, "===").ok();
|
||||
out
|
||||
}
|
||||
|
||||
fn kind_label(kind: EntityKind) -> &'static str {
|
||||
match kind {
|
||||
EntityKind::Player => "player",
|
||||
EntityKind::Npc => "npc",
|
||||
EntityKind::Object => "obj",
|
||||
EntityKind::Terrain => "terrain",
|
||||
}
|
||||
}
|
||||
|
||||
fn sector_label(sector: VisibilitySector) -> &'static str {
|
||||
match sector {
|
||||
VisibilitySector::Forward => "Forward",
|
||||
VisibilitySector::Peripheral => "Periph",
|
||||
}
|
||||
}
|
||||
|
||||
fn relationship_label(rel: RelationshipState) -> &'static str {
|
||||
match rel {
|
||||
RelationshipState::Unknown => "Unknown",
|
||||
RelationshipState::Known => "Known",
|
||||
RelationshipState::Friendly => "Friendly",
|
||||
RelationshipState::PersonOfInterest => "POI",
|
||||
RelationshipState::Hostile => "Hostile",
|
||||
}
|
||||
}
|
||||
|
||||
fn observation_label(obs: &EntityVisibility) -> &'static str {
|
||||
match obs {
|
||||
EntityVisibility::Visible => "Visible",
|
||||
EntityVisibility::Remembered { .. } => "Remembered",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::time::{DayPhase, TickRate};
|
||||
|
||||
fn make_snapshot() -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 252,
|
||||
day_phase: DayPhase::Morning,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::East,
|
||||
player_stance: MovementStance::Walk,
|
||||
player_inventory: vec![],
|
||||
entities: vec![
|
||||
VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 15.0,
|
||||
y: 10.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 18.0,
|
||||
y: 10.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Known,
|
||||
observation: EntityVisibility::Visible,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 200,
|
||||
x: 16.0,
|
||||
y: 9.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Object,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
},
|
||||
],
|
||||
visible_tiles: vec![VisibleTile {
|
||||
x: 15,
|
||||
y: 10,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
}],
|
||||
nearby_interactions: vec![NearbyInteraction {
|
||||
entity_id: 100,
|
||||
entity_type: EntityKind::Npc,
|
||||
distance: 3,
|
||||
verbs: vec![
|
||||
VerbOption {
|
||||
kind: VerbKind::Talk,
|
||||
label: "Talk".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
},
|
||||
VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "ExamineNpc".into(),
|
||||
priority: 2,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
object_type: None,
|
||||
contradicted: false,
|
||||
}],
|
||||
current_monologue: Some(MonologueEvent {
|
||||
id: "mono_test_1".into(),
|
||||
text: "Something about this manifest doesn't add up.".into(),
|
||||
duration_seconds: 3.0,
|
||||
}),
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_contains_tick_and_position() {
|
||||
let text = format_snapshot_text(&make_snapshot());
|
||||
assert!(text.contains("Tick 42"));
|
||||
assert!(text.contains("Player (15,10)"));
|
||||
assert!(text.contains("facing East"));
|
||||
assert!(text.contains("Stance: Walk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_time_formatted() {
|
||||
let text = format_snapshot_text(&make_snapshot());
|
||||
assert!(text.contains("Day 0, 04:12 (Morning)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_sorted_by_distance() {
|
||||
let text = format_snapshot_text(&make_snapshot());
|
||||
// obj:200 at (16,9) is distance 2 from player (15,10)
|
||||
// npc:100 at (18,10) is distance 3 from player (15,10)
|
||||
let obj_pos = text.find("obj:200").unwrap();
|
||||
let npc_pos = text.find("npc:100").unwrap();
|
||||
assert!(
|
||||
obj_pos < npc_pos,
|
||||
"obj:200 (d=2) should appear before npc:100 (d=3)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_excluded_from_entity_list() {
|
||||
let text = format_snapshot_text(&make_snapshot());
|
||||
assert!(text.contains("Entities (2):"));
|
||||
assert!(!text.contains("player:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactions_rendered() {
|
||||
let text = format_snapshot_text(&make_snapshot());
|
||||
assert!(text.contains("Interactions (1):"));
|
||||
assert!(text.contains("Talk(1)"));
|
||||
assert!(text.contains("distance=3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monologue_rendered() {
|
||||
let text = format_snapshot_text(&make_snapshot());
|
||||
assert!(text.contains("Monologue: \"Something about this manifest doesn't add up.\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogue_rendered_when_present() {
|
||||
let mut snap = make_snapshot();
|
||||
snap.dialogue_response = Some(DialogueResponseEvent {
|
||||
line_id: "line_test".into(),
|
||||
text: "Welcome to the docks.".into(),
|
||||
speaker_entity_id: 100,
|
||||
});
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Dialogue: [npc:100] \"Welcome to the docks.\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_recognitions_rendered() {
|
||||
let mut snap = make_snapshot();
|
||||
snap.pending_recognitions = vec![PendingRecognitionWire {
|
||||
entity_id: 104,
|
||||
x: 19.0,
|
||||
y: 12.0,
|
||||
z: 0,
|
||||
remaining_ticks: 5,
|
||||
total_delay_ticks: 8,
|
||||
}];
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Pending recognitions: 1"));
|
||||
assert!(text.contains("npc:104 at (19,12) 3/8 ticks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_rendered() {
|
||||
let mut snap = make_snapshot();
|
||||
snap.player_inventory = vec![
|
||||
InventoryItem {
|
||||
item_id: 300,
|
||||
name: "Manifest".into(),
|
||||
slot: 0,
|
||||
},
|
||||
InventoryItem {
|
||||
item_id: 301,
|
||||
name: "Keycard".into(),
|
||||
slot: 3,
|
||||
},
|
||||
];
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Inventory: 2/9"));
|
||||
assert!(text.contains("item:300(slot-0)"));
|
||||
assert!(text.contains("item:301(slot-3)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_snapshot_no_panic() {
|
||||
let snap = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 0,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::Walk,
|
||||
player_inventory: vec![],
|
||||
entities: vec![],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
assert!(text.contains("Player (-1,-1)"));
|
||||
assert!(text.contains("Tiles: 0 visible"));
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,11 @@ const MAX_WALK_DEPTH: usize = 100;
|
||||
/// Stops recursing at MAX_WALK_DEPTH to guard against symlink loops.
|
||||
fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap<PathBuf, SystemTime>, depth: usize) {
|
||||
if depth >= MAX_WALK_DEPTH {
|
||||
tracing::warn!("walk_yaml: max depth {} reached at {:?}, stopping", MAX_WALK_DEPTH, dir);
|
||||
tracing::warn!(
|
||||
"walk_yaml: max depth {} reached at {:?}, stopping",
|
||||
MAX_WALK_DEPTH,
|
||||
dir
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
|
||||
@@ -197,9 +197,10 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
|
||||
// Register in EntityRegistry for StableId mapping
|
||||
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.insert((StableEntityId(stable_id), ContentSlug(profile.canonical_id.clone())));
|
||||
world.entity_mut(entity).insert((
|
||||
StableEntityId(stable_id),
|
||||
ContentSlug(profile.canonical_id.clone()),
|
||||
));
|
||||
|
||||
result
|
||||
.npc_ids
|
||||
|
||||
@@ -214,11 +214,7 @@ mod tests {
|
||||
id_first, id_second,
|
||||
"Re-registration must assign a new StableId"
|
||||
);
|
||||
assert_eq!(
|
||||
id_second,
|
||||
StableId(1),
|
||||
"Counter advances monotonically"
|
||||
);
|
||||
assert_eq!(id_second, StableId(1), "Counter advances monotonically");
|
||||
assert_eq!(registry.len(), 1);
|
||||
|
||||
// New mapping is bidirectionally correct
|
||||
|
||||
+3
-1
@@ -162,7 +162,9 @@ fn setup_proof_room(app: &mut App) {
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::movement::{
|
||||
PlayerCharacter, TilePosition, WalkabilityMap,
|
||||
};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
|
||||
@@ -26,10 +26,8 @@ impl Plugin for PerceptionPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
anomaly::clear_anomaly_markers
|
||||
.before(anomaly::detect_anomalies),
|
||||
anomaly::detect_anomalies
|
||||
.before(observation::emit_observation_events),
|
||||
anomaly::clear_anomaly_markers.before(anomaly::detect_anomalies),
|
||||
anomaly::detect_anomalies.before(observation::emit_observation_events),
|
||||
cognitive_delay::process_cognitive_delay
|
||||
.after(observation::emit_observation_events)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
|
||||
@@ -15,9 +15,9 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
|
||||
use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::stance::Stance;
|
||||
|
||||
@@ -1948,7 +1948,11 @@ fn equidistant_npcs_produce_stable_snapshot_ordering() {
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
assert_eq!(npc_ids.len(), 3, "all three equidistant NPCs should be visible");
|
||||
assert_eq!(
|
||||
npc_ids.len(),
|
||||
3,
|
||||
"all three equidistant NPCs should be visible"
|
||||
);
|
||||
|
||||
// Entity IDs must be in strictly ascending order (Fix B sort guarantee)
|
||||
for i in 1..npc_ids.len() {
|
||||
|
||||
@@ -19,7 +19,9 @@ use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::bridge::types::{DialogueResponseEvent, RelationshipState};
|
||||
use crate::content::line_pool::{AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier};
|
||||
use crate::content::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||
};
|
||||
use crate::content::LinePoolIndexResource;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
@@ -219,7 +221,11 @@ pub fn derive_situations(
|
||||
/// - Topic match: +2 per matching topic
|
||||
///
|
||||
/// Returns 0 only for lines on cooldown (caller handles).
|
||||
pub fn score_line(line: &IndexedDialogueLine, npc_mood: Option<Mood>, active_topics: &[Topic]) -> u32 {
|
||||
pub fn score_line(
|
||||
line: &IndexedDialogueLine,
|
||||
npc_mood: Option<Mood>,
|
||||
active_topics: &[Topic],
|
||||
) -> u32 {
|
||||
let mut score: u32 = 1; // Base score — no line is excluded by Layer 4
|
||||
|
||||
// Mood match
|
||||
@@ -318,8 +324,14 @@ pub fn process_talk_interaction(
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, observer_kg, talk_request, mut response_buffer, mut cooldown, active_dialogue_opt)) =
|
||||
player_query.single_mut()
|
||||
let Ok((
|
||||
player_entity,
|
||||
observer_kg,
|
||||
talk_request,
|
||||
mut response_buffer,
|
||||
mut cooldown,
|
||||
active_dialogue_opt,
|
||||
)) = player_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -784,9 +796,14 @@ mod tests {
|
||||
let mut match_count = 0;
|
||||
for seed in 0..100 {
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed);
|
||||
if let Some(line) =
|
||||
select_dialogue_line(&candidates, Some(Mood::Worried), &[], &cooldown, 0, &mut rng)
|
||||
{
|
||||
if let Some(line) = select_dialogue_line(
|
||||
&candidates,
|
||||
Some(Mood::Worried),
|
||||
&[],
|
||||
&cooldown,
|
||||
0,
|
||||
&mut rng,
|
||||
) {
|
||||
if line.id == "matched" {
|
||||
match_count += 1;
|
||||
}
|
||||
@@ -869,9 +886,10 @@ mod tests {
|
||||
role: "dock-worker".to_string(),
|
||||
lines,
|
||||
};
|
||||
index
|
||||
.dialogue
|
||||
.insert(("the-terminal".to_string(), "dock-worker".to_string()), pool);
|
||||
index.dialogue.insert(
|
||||
("the-terminal".to_string(), "dock-worker".to_string()),
|
||||
pool,
|
||||
);
|
||||
index
|
||||
}
|
||||
|
||||
@@ -1009,10 +1027,11 @@ mod tests {
|
||||
let mut seen_ids: Vec<String> = Vec::new();
|
||||
for seed in 0..20 {
|
||||
// Reset for each iteration
|
||||
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(TalkRequest { target: npc });
|
||||
.get_mut::<DialogueResponseBuffer>(player)
|
||||
.unwrap()
|
||||
.response = None;
|
||||
world.entity_mut(player).insert(TalkRequest { target: npc });
|
||||
world.insert_resource(SimRng::new(seed));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
@@ -1020,7 +1039,11 @@ mod tests {
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
if let Some(resp) = &world.get::<DialogueResponseBuffer>(player).unwrap().response {
|
||||
if let Some(resp) = &world
|
||||
.get::<DialogueResponseBuffer>(player)
|
||||
.unwrap()
|
||||
.response
|
||||
{
|
||||
if !seen_ids.contains(&resp.line_id) {
|
||||
seen_ids.push(resp.line_id.clone());
|
||||
}
|
||||
@@ -1046,9 +1069,7 @@ mod tests {
|
||||
world.insert_resource(LinePoolIndexResource(index));
|
||||
|
||||
// NPC without DialogueProfile
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
@@ -1142,10 +1163,11 @@ mod tests {
|
||||
);
|
||||
|
||||
// Second talk — same tick, line on cooldown
|
||||
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(TalkRequest { target: npc });
|
||||
.get_mut::<DialogueResponseBuffer>(player)
|
||||
.unwrap()
|
||||
.response = None;
|
||||
world.entity_mut(player).insert(TalkRequest { target: npc });
|
||||
|
||||
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule2.add_systems(process_talk_interaction);
|
||||
|
||||
@@ -102,12 +102,7 @@ pub fn process_player_input(
|
||||
for input in inputs {
|
||||
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
|
||||
// Only Pause/Unpause are processed — everything else is discarded.
|
||||
if paused
|
||||
&& !matches!(
|
||||
input.action,
|
||||
PlayerAction::Pause | PlayerAction::Unpause
|
||||
)
|
||||
{
|
||||
if paused && !matches!(input.action, PlayerAction::Pause | PlayerAction::Unpause) {
|
||||
continue;
|
||||
}
|
||||
match input.action {
|
||||
@@ -190,14 +185,20 @@ pub fn process_player_input(
|
||||
handle_place(&mut commands, ®istry, &player_query, target_entity_id);
|
||||
}
|
||||
Some("Talk") => {
|
||||
handle_talk(&mut commands, ®istry, &player_query, &all_positions, target_entity_id);
|
||||
handle_talk(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&all_positions,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only",
|
||||
target_entity_id,
|
||||
verb,
|
||||
);
|
||||
"Interact: target={:?}, verb={:?} — logged only",
|
||||
target_entity_id,
|
||||
verb,
|
||||
);
|
||||
}
|
||||
},
|
||||
PlayerAction::WalkAway => {
|
||||
@@ -363,7 +364,9 @@ fn handle_talk(
|
||||
|
||||
// Server-side range check: reject Talk if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
@@ -1312,11 +1315,7 @@ mod tests {
|
||||
);
|
||||
let mut query = world.query::<&Stance>();
|
||||
let stance = query.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
stance.0,
|
||||
MovementStance::Walk,
|
||||
"Stance unchanged in batch"
|
||||
);
|
||||
assert_eq!(stance.0, MovementStance::Walk, "Stance unchanged in batch");
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Paused,
|
||||
|
||||
@@ -39,18 +39,12 @@ pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
|
||||
/// Fire DURING cognitive delay (when grey blob appears). Future: move to
|
||||
/// content pools with trigger="observe_anomaly" + character match.
|
||||
const RECOGNITION_LINES: &[(&str, &str)] = &[
|
||||
(
|
||||
"recognition_01",
|
||||
"Wait \u{2014} I know that walk.",
|
||||
),
|
||||
("recognition_01", "Wait \u{2014} I know that walk."),
|
||||
(
|
||||
"recognition_02",
|
||||
"Those footsteps... I've heard that pattern before.",
|
||||
),
|
||||
(
|
||||
"recognition_03",
|
||||
"Something about that silhouette...",
|
||||
),
|
||||
("recognition_03", "Something about that silhouette..."),
|
||||
];
|
||||
|
||||
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
|
||||
@@ -258,9 +252,9 @@ pub fn trigger_recognition_monologue(
|
||||
let pending = cognitive_delay.pending_mut();
|
||||
let target_idx = {
|
||||
// First pass: anomalous + unfired
|
||||
let anomaly_idx = pending.iter().position(|p| {
|
||||
!p.monologue_fired && anomaly_markers.get(p.target).is_ok()
|
||||
});
|
||||
let anomaly_idx = pending
|
||||
.iter()
|
||||
.position(|p| !p.monologue_fired && anomaly_markers.get(p.target).is_ok());
|
||||
if let Some(idx) = anomaly_idx {
|
||||
Some(idx)
|
||||
} else {
|
||||
@@ -872,10 +866,10 @@ mod tests {
|
||||
// trigger_recognition_monologue tests (#451, D-060)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::perception::cognitive_delay::{
|
||||
CognitiveDelay, PendingRecognition, RecognitionTrigger, NORMAL_DELAY_TICKS,
|
||||
};
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
fn setup_recognition_world() -> World {
|
||||
let mut world = World::new();
|
||||
@@ -957,7 +951,12 @@ mod tests {
|
||||
// First tick: fires
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_some(),
|
||||
world
|
||||
.query::<&MonologueBuffer>()
|
||||
.single(&world)
|
||||
.unwrap()
|
||||
.event
|
||||
.is_some(),
|
||||
"first tick should fire"
|
||||
);
|
||||
|
||||
@@ -967,7 +966,12 @@ mod tests {
|
||||
// Second tick: should NOT fire (monologue_fired = true)
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_none(),
|
||||
world
|
||||
.query::<&MonologueBuffer>()
|
||||
.single(&world)
|
||||
.unwrap()
|
||||
.event
|
||||
.is_none(),
|
||||
"second tick should not fire (already fired for this recognition)"
|
||||
);
|
||||
}
|
||||
@@ -1053,9 +1057,7 @@ mod tests {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let normal_target = world.spawn_empty().id();
|
||||
let anomalous_target = world
|
||||
.spawn(crate::perception::anomaly::AnomalyMarker)
|
||||
.id();
|
||||
let anomalous_target = world.spawn(crate::perception::anomaly::AnomalyMarker).id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
// Normal entity added first
|
||||
|
||||
@@ -917,12 +917,14 @@ mod tests {
|
||||
|
||||
// Entity with lower bits processes first and claims the target
|
||||
assert_eq!(
|
||||
pos_lower, target,
|
||||
pos_lower,
|
||||
target,
|
||||
"entity with lower Entity::to_bits() ({}) should win the tile",
|
||||
lower.to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
pos_higher, higher_origin,
|
||||
pos_higher,
|
||||
higher_origin,
|
||||
"entity with higher Entity::to_bits() ({}) should stay at origin",
|
||||
higher.to_bits()
|
||||
);
|
||||
|
||||
@@ -86,7 +86,10 @@ fn input_roundtrip_over_tcp() {
|
||||
match bridge.receive_inputs() {
|
||||
Ok(inputs) if !inputs.is_empty() => break inputs,
|
||||
Ok(_) => {
|
||||
assert!(std::time::Instant::now() < deadline, "timed out waiting for inputs");
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for inputs"
|
||||
);
|
||||
thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
Err(e) => panic!("failed to receive inputs: {}", e),
|
||||
@@ -150,7 +153,10 @@ fn tcp_bridge_eof_returns_error() {
|
||||
match bridge.receive_inputs() {
|
||||
Ok(inputs) if inputs.is_empty() => {
|
||||
// WouldBlock — client hasn't disconnected yet, retry
|
||||
assert!(std::time::Instant::now() < deadline, "timed out waiting for EOF");
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for EOF"
|
||||
);
|
||||
thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
Ok(inputs) => panic!("expected Disconnected error, got {} inputs", inputs.len()),
|
||||
|
||||
@@ -16,8 +16,8 @@ use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry,
|
||||
ToleranceThreshold, Want, WantKind,
|
||||
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
|
||||
Want, WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
@@ -213,10 +213,7 @@ fn build_deterministic_app(seed: u64) -> App {
|
||||
|
||||
/// Run the simulation for a fixed number of ticks with predetermined inputs.
|
||||
/// Returns serialized snapshots for each tick.
|
||||
fn run_simulation(
|
||||
seed: u64,
|
||||
inputs: &[Vec<PlayerInput>],
|
||||
) -> Vec<Vec<u8>> {
|
||||
fn run_simulation(seed: u64, inputs: &[Vec<PlayerInput>]) -> Vec<Vec<u8>> {
|
||||
let mut app = build_deterministic_app(seed);
|
||||
let mut snapshots = Vec::with_capacity(inputs.len());
|
||||
|
||||
@@ -334,7 +331,8 @@ fn gauntlet_deterministic_replay() {
|
||||
|
||||
for (tick, (s1, s2)) in run1.iter().zip(run2.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
s1, s2,
|
||||
s1,
|
||||
s2,
|
||||
"snapshot at tick {} differs between runs ({} vs {} bytes)",
|
||||
tick,
|
||||
s1.len(),
|
||||
|
||||
@@ -279,11 +279,11 @@ fn generate_msgpack_fixtures() {
|
||||
// Tests that GDScript can decode full ObserverSnapshot structs when the tick
|
||||
// field crosses encoding format boundaries.
|
||||
let boundary_snapshots: [(u64, &str); 5] = [
|
||||
(0, "snapshot_boundary_tick_0"), // pos fixint
|
||||
(127, "snapshot_boundary_tick_127"), // pos fixint max
|
||||
(32767, "snapshot_boundary_tick_32767"), // int16/uint16 asymmetry
|
||||
(0, "snapshot_boundary_tick_0"), // pos fixint
|
||||
(127, "snapshot_boundary_tick_127"), // pos fixint max
|
||||
(32767, "snapshot_boundary_tick_32767"), // int16/uint16 asymmetry
|
||||
(2147483647, "snapshot_boundary_tick_2b31m1"), // int32/uint32 asymmetry
|
||||
(4294967296, "snapshot_boundary_tick_2b32"), // int64 minimum
|
||||
(4294967296, "snapshot_boundary_tick_2b32"), // int64 minimum
|
||||
];
|
||||
|
||||
for (tick, name) in &boundary_snapshots {
|
||||
|
||||
@@ -139,8 +139,9 @@ fn all_fixtures_deserialize() {
|
||||
|
||||
if name.starts_with("snapshot_boundary") {
|
||||
// Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check
|
||||
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize boundary snapshot fixture {}: {}", name, e));
|
||||
rmp_serde::from_slice::<ObserverSnapshot>(&bytes).unwrap_or_else(|e| {
|
||||
panic!("deserialize boundary snapshot fixture {}: {}", name, e)
|
||||
});
|
||||
} else if name.starts_with("snapshot") {
|
||||
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
|
||||
@@ -694,19 +695,48 @@ fn nearby_interaction_contradicted_roundtrip() {
|
||||
/// int 32 (-32769 to -2^31), int 64 (-2^31-1 to -2^63).
|
||||
const BOUNDARY_VALUES: [i64; 41] = [
|
||||
// Positive boundaries (25 values)
|
||||
0, 1, 126, 127, // pos fixint
|
||||
128, 129, 254, 255, // uint 8
|
||||
256, 257, 32766, 32767, // int 16 / uint 16 asymmetry
|
||||
32768, 32769, 65534, 65535, // uint 16
|
||||
65536, 65537, 2147483646, 2147483647, // int 32 / uint 32 asymmetry
|
||||
2147483648, 4294967294, 4294967295, // uint 32
|
||||
4294967296, i64::MAX, // int 64
|
||||
0,
|
||||
1,
|
||||
126,
|
||||
127, // pos fixint
|
||||
128,
|
||||
129,
|
||||
254,
|
||||
255, // uint 8
|
||||
256,
|
||||
257,
|
||||
32766,
|
||||
32767, // int 16 / uint 16 asymmetry
|
||||
32768,
|
||||
32769,
|
||||
65534,
|
||||
65535, // uint 16
|
||||
65536,
|
||||
65537,
|
||||
2147483646,
|
||||
2147483647, // int 32 / uint 32 asymmetry
|
||||
2147483648,
|
||||
4294967294,
|
||||
4294967295, // uint 32
|
||||
4294967296,
|
||||
i64::MAX, // int 64
|
||||
// Negative boundaries (16 values)
|
||||
-1, -31, -32, // neg fixint
|
||||
-33, -34, -127, -128, // int 8
|
||||
-129, -130, -32767, -32768, // int 16
|
||||
-32769, -2147483647, -2147483648, // int 32
|
||||
-2147483649, i64::MIN, // int 64
|
||||
-1,
|
||||
-31,
|
||||
-32, // neg fixint
|
||||
-33,
|
||||
-34,
|
||||
-127,
|
||||
-128, // int 8
|
||||
-129,
|
||||
-130,
|
||||
-32767,
|
||||
-32768, // int 16
|
||||
-32769,
|
||||
-2147483647,
|
||||
-2147483648, // int 32
|
||||
-2147483649,
|
||||
i64::MIN, // int 64
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
||||
Generated
+1707
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "settled-reach-test-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Test client for the Settled Reach simulation server. Connects via TCP, receives ObserverSnapshots, sends replay inputs."
|
||||
|
||||
[[bin]]
|
||||
name = "settled-reach-test-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
settled-reach-server = { path = "../../server" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
rmp-serde = "1"
|
||||
@@ -0,0 +1,104 @@
|
||||
// Golden file comparison for test-client.
|
||||
// Compares the final ObserverSnapshot (as JSON) against a golden file.
|
||||
// Reports field-by-field differences with JSON paths.
|
||||
|
||||
use serde_json::Value;
|
||||
use settled_reach_server::bridge::types::ObserverSnapshot;
|
||||
use std::path::Path;
|
||||
|
||||
/// Compare an ObserverSnapshot against a golden JSON file.
|
||||
/// Returns a list of difference descriptions (empty = match).
|
||||
pub fn compare_golden(
|
||||
golden_path: &Path,
|
||||
snapshot: &ObserverSnapshot,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let golden_str = std::fs::read_to_string(golden_path).map_err(|e| {
|
||||
format!(
|
||||
"failed to read golden file {}: {}",
|
||||
golden_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let golden: Value = serde_json::from_str(&golden_str)
|
||||
.map_err(|e| format!("failed to parse golden file: {}", e))?;
|
||||
let actual: Value = serde_json::to_value(snapshot)
|
||||
.map_err(|e| format!("failed to serialize snapshot: {}", e))?;
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
diff_values("", &golden, &actual, &mut diffs);
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
|
||||
match (expected, actual) {
|
||||
(Value::Object(e), Value::Object(a)) => {
|
||||
for key in e.keys() {
|
||||
let child_path = if path.is_empty() {
|
||||
format!(".{}", key)
|
||||
} else {
|
||||
format!("{}.{}", path, key)
|
||||
};
|
||||
match a.get(key) {
|
||||
Some(av) => diff_values(&child_path, &e[key], av, diffs),
|
||||
None => diffs.push(format!(
|
||||
"{}: expected {}, got <missing>",
|
||||
child_path,
|
||||
format_value(&e[key])
|
||||
)),
|
||||
}
|
||||
}
|
||||
for key in a.keys() {
|
||||
if !e.contains_key(key) {
|
||||
let child_path = if path.is_empty() {
|
||||
format!(".{}", key)
|
||||
} else {
|
||||
format!("{}.{}", path, key)
|
||||
};
|
||||
diffs.push(format!(
|
||||
"{}: expected <missing>, got {}",
|
||||
child_path,
|
||||
format_value(&a[key])
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(e), Value::Array(a)) => {
|
||||
let max_len = e.len().max(a.len());
|
||||
for i in 0..max_len {
|
||||
let child_path = format!("{}[{}]", path, i);
|
||||
match (e.get(i), a.get(i)) {
|
||||
(Some(ev), Some(av)) => diff_values(&child_path, ev, av, diffs),
|
||||
(Some(ev), None) => diffs.push(format!(
|
||||
"{}: expected {}, got <missing>",
|
||||
child_path,
|
||||
format_value(ev)
|
||||
)),
|
||||
(None, Some(av)) => diffs.push(format!(
|
||||
"{}: expected <missing>, got {}",
|
||||
child_path,
|
||||
format_value(av)
|
||||
)),
|
||||
(None, None) => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if expected != actual {
|
||||
diffs.push(format!(
|
||||
"{}: expected {}, got {}",
|
||||
path,
|
||||
format_value(expected),
|
||||
format_value(actual)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_value(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => format!("{:?}", s),
|
||||
Value::Null => "null".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// test-client: Test client for the Settled Reach simulation server.
|
||||
//
|
||||
// Connects via TCP, receives ObserverSnapshots, optionally sends replay
|
||||
// inputs, and outputs snapshots as text/JSON. Supports golden file
|
||||
// comparison for deterministic regression testing.
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 = success
|
||||
// 1 = golden file mismatch
|
||||
// 2 = connection/protocol error
|
||||
|
||||
use clap::Parser;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::text_renderer::format_snapshot_text;
|
||||
use settled_reach_server::bridge::types::ObserverSnapshot;
|
||||
|
||||
mod golden;
|
||||
mod replay;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "test-client",
|
||||
about = "Test client for the Settled Reach simulation server"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Server address (host:port)
|
||||
#[arg(long, default_value = "127.0.0.1:9876")]
|
||||
connect: String,
|
||||
|
||||
/// JSONL replay file (one JSON array of PlayerInput per tick)
|
||||
#[arg(long)]
|
||||
replay: Option<PathBuf>,
|
||||
|
||||
/// Output format: structured text to stdout (default)
|
||||
#[arg(long)]
|
||||
text: bool,
|
||||
|
||||
/// Output format: JSON snapshots to stdout
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
/// No output (CI assertions only)
|
||||
#[arg(long)]
|
||||
quiet: bool,
|
||||
|
||||
/// Compare final snapshot against golden JSON file, exit 1 on diff
|
||||
#[arg(long)]
|
||||
golden: Option<PathBuf>,
|
||||
|
||||
/// Disconnect after N ticks
|
||||
#[arg(long)]
|
||||
ticks: Option<u64>,
|
||||
}
|
||||
|
||||
enum OutputMode {
|
||||
Text,
|
||||
Json,
|
||||
Quiet,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let output_mode = if cli.quiet {
|
||||
OutputMode::Quiet
|
||||
} else if cli.json {
|
||||
OutputMode::Json
|
||||
} else {
|
||||
OutputMode::Text
|
||||
};
|
||||
|
||||
// Connect to server
|
||||
let stream = match TcpStream::connect(&cli.connect) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Connection error: {} (address: {})", e, cli.connect);
|
||||
process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap_or_else(|e| {
|
||||
eprintln!("Failed to clone TCP stream: {}", e);
|
||||
process::exit(2);
|
||||
}));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
// Load replay inputs if provided
|
||||
let replay_inputs = cli.replay.as_ref().map(|path| {
|
||||
replay::load_replay(path).unwrap_or_else(|e| {
|
||||
eprintln!("Replay load error: {}", e);
|
||||
process::exit(2);
|
||||
})
|
||||
});
|
||||
|
||||
let mut tick_count: u64 = 0;
|
||||
let mut last_snapshot: Option<ObserverSnapshot> = None;
|
||||
|
||||
loop {
|
||||
// Receive snapshot (blocking read)
|
||||
let payload = match read_framed(&mut reader) {
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
// Server closed connection — normal shutdown
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Read error: {}", e);
|
||||
process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let snapshot: ObserverSnapshot = match rmp_serde::from_slice(&payload) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Deserialization error: {}", e);
|
||||
process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
tick_count += 1;
|
||||
|
||||
// Output snapshot
|
||||
match output_mode {
|
||||
OutputMode::Text => {
|
||||
print!("{}", format_snapshot_text(&snapshot));
|
||||
}
|
||||
OutputMode::Json => {
|
||||
let json = serde_json::to_string_pretty(&snapshot).unwrap();
|
||||
println!("{}", json);
|
||||
}
|
||||
OutputMode::Quiet => {}
|
||||
}
|
||||
|
||||
last_snapshot = Some(snapshot);
|
||||
|
||||
// Check tick limit before sending next input
|
||||
if let Some(max_ticks) = cli.ticks {
|
||||
if tick_count >= max_ticks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Send inputs for next tick
|
||||
let inputs = replay_inputs
|
||||
.as_ref()
|
||||
.and_then(|r| r.get(tick_count as usize - 1))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let input_payload = rmp_serde::to_vec(&inputs).unwrap_or_else(|e| {
|
||||
eprintln!("Input serialization error: {}", e);
|
||||
process::exit(2);
|
||||
});
|
||||
if let Err(e) = write_framed(&mut writer, &input_payload) {
|
||||
eprintln!("Write error: {}", e);
|
||||
process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Golden file comparison
|
||||
if let Some(golden_path) = &cli.golden {
|
||||
let snapshot = match last_snapshot {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("No snapshot received for golden comparison");
|
||||
process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let diffs = golden::compare_golden(golden_path, &snapshot).unwrap_or_else(|e| {
|
||||
eprintln!("Golden file error: {}", e);
|
||||
process::exit(2);
|
||||
});
|
||||
|
||||
if !diffs.is_empty() {
|
||||
eprintln!("GOLDEN FILE MISMATCH: {}", golden_path.display());
|
||||
for diff in &diffs {
|
||||
eprintln!(" {}", diff);
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// JSONL replay file loader for test-client.
|
||||
// Each line is a JSON array of PlayerInput for one tick.
|
||||
// Empty array = idle tick (no input sent).
|
||||
|
||||
use settled_reach_server::bridge::types::PlayerInput;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load a JSONL replay file. Returns one Vec<PlayerInput> per tick.
|
||||
pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read replay file {}: {}", path.display(), e))?;
|
||||
|
||||
let mut ticks = Vec::new();
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let inputs: Vec<PlayerInput> =
|
||||
serde_json::from_str(line).map_err(|e| format!("replay line {}: {}", i + 1, e))?;
|
||||
ticks.push(inputs);
|
||||
}
|
||||
Ok(ticks)
|
||||
}
|
||||
Reference in New Issue
Block a user