fix(server): PR #132 review round 2 — 14 actionable comments addressed
Blockers (4): - Wire cargo deny check into pre-pr-server (was dead config) (#1) - ConfirmBookmark idempotency guard: SimError ProtocolError on retry (#2) - D-080 amendment: transfer_npc_knowledge retained-but-dormant honest doc (#3) - SelectedBookmark v0.2 transient scope; save/load deferred to #863 (#4) Issues (8): - ConfirmBookmark validation tests: unknown id, invalid location, valid path, double-confirm guard (#5) - snapshot_with_bookmark_catalog fixture for client #618 decode tests (#6) - generate_brands: replace 5 raw .unwrap() with eprintln+exit pattern (#7) - npc_knowledge_transfer.rs: stale run_npc_conversations refs cleaned (#8) - monologue.rs: residual D-078 "overheard conversations" doc removed (#9) - 5 test files: orphan blank lines from removed conversation_* fields (#10) - BookmarkCatalog: add PartialEq, Eq derives (matches sibling) (#11) Nits (2): - culture_tag doc: describe BookmarkRegistry::build_catalog behavior, remove "until #679 lands" placeholder (#13) - generate_brands seed=1 canonical comment (#14) Follow-ups filed: - #862 — BookmarkPlugin::new(registry) injection (#12 deferred) - #863 — Wire SelectedBookmark into SaveState (Sprint 37) Pre-pr-server: fmt clean, clippy clean, deny clean, build clean. nextest save_io failures pre-existing parallelism issue (sequential cargo test --lib passes 20/20). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1208,6 +1208,21 @@ fn handle_confirm_bookmark(
|
||||
return;
|
||||
}
|
||||
if let Some(ref mut sel) = selected_bookmark {
|
||||
if sel.bookmark_id.is_some() {
|
||||
let msg = format!(
|
||||
"ConfirmBookmark: bookmark already confirmed ({}), ignoring retry",
|
||||
sel.bookmark_id.as_deref().unwrap_or("?")
|
||||
);
|
||||
tracing::warn!("{}", msg);
|
||||
if let Some(ref mut buf) = sim_error_buf {
|
||||
buf.push(SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
message: msg,
|
||||
tick,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
sel.bookmark_id = Some(bookmark_id.clone());
|
||||
sel.starting_location_id = Some(starting_location_id.clone());
|
||||
tracing::info!(
|
||||
@@ -2559,4 +2574,168 @@ mod tests {
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
||||
}
|
||||
|
||||
fn make_bookmark_world() -> bevy_ecs::world::World {
|
||||
use crate::bookmark::types::{BookmarkDefinition, BookmarkId, CareerKind};
|
||||
use crate::bookmark::{BookmarkRegistry, SelectedBookmark};
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
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.init_resource::<SelectedBookmark>();
|
||||
world.init_resource::<SimErrorBuffer>();
|
||||
|
||||
let mut registry = BookmarkRegistry::default();
|
||||
registry.insert(BookmarkDefinition {
|
||||
id: BookmarkId("test_bookmark".to_string()),
|
||||
title: "Test Bookmark".into(),
|
||||
subtitle: String::new(),
|
||||
flavor: String::new(),
|
||||
default_location: "Loc A".into(),
|
||||
allowed_locations: vec!["Loc A".into(), "Loc B".into()],
|
||||
career: CareerKind::Tycoon,
|
||||
starting_capital_tractus: 1_000,
|
||||
available: true,
|
||||
});
|
||||
world.insert_resource(registry);
|
||||
|
||||
world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_unknown_id_emits_sim_error() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "no_such_bookmark".into(),
|
||||
starting_location_id: "Loc A".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expected one SimError for unknown bookmark"
|
||||
);
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("unknown bookmark_id"));
|
||||
assert!(
|
||||
world
|
||||
.resource::<crate::bookmark::SelectedBookmark>()
|
||||
.bookmark_id
|
||||
.is_none(),
|
||||
"SelectedBookmark must not be set on error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_invalid_location_emits_sim_error() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Not A Location".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expected one SimError for invalid location"
|
||||
);
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("not in allowed_locations"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_valid_inputs_populate_selected_bookmark() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc B".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"no errors expected for valid ConfirmBookmark"
|
||||
);
|
||||
|
||||
let sel = world.resource::<crate::bookmark::SelectedBookmark>();
|
||||
assert_eq!(sel.bookmark_id.as_deref(), Some("test_bookmark"));
|
||||
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_double_confirm_emits_sim_error() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
|
||||
// Both confirms at tick=0: processed in order within the same run.
|
||||
// First succeeds; second hits the idempotency guard.
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc A".into(),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc B".into(),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(errors.len(), 1, "double-confirm must emit ProtocolError");
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("already confirmed"));
|
||||
|
||||
// Selection must remain the original, not overwritten
|
||||
let sel = world.resource::<crate::bookmark::SelectedBookmark>();
|
||||
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc A"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,8 +395,8 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
|
||||
|
||||
/// Event-driven monologue trigger system (#119, D-035).
|
||||
///
|
||||
/// Checks observation events, sound events, overheard conversations, and
|
||||
/// completed dialogues for monologue-worthy triggers. Fires at most one
|
||||
/// Checks observation events, sound events, and completed dialogues for
|
||||
/// monologue-worthy triggers. Fires at most one
|
||||
/// monologue per tick. Event-driven — no cooldown gate. Updates
|
||||
/// `last_fired_tick` so v0.2 periodic triggers can respect the recency window.
|
||||
///
|
||||
|
||||
@@ -115,7 +115,7 @@ impl TransferCandidate {
|
||||
/// If the player is within VOICE_RANGE_TILES, they gain entity-level knowledge
|
||||
/// about both NPCs at `Suspects` confidence (`Heard` source).
|
||||
///
|
||||
/// System ordering: after(run_npc_conversations).
|
||||
/// Runs in [`TickPhase::Simulation`] with no ordering constraint.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn transfer_npc_knowledge(
|
||||
time: Res<SimulationTime>,
|
||||
@@ -203,10 +203,9 @@ pub fn transfer_npc_knowledge(
|
||||
}
|
||||
|
||||
// --- Dual-mutable KG access ---
|
||||
// Transfer is one-directional per conversation tick: speaker → partner.
|
||||
// If both participants are Active NPCs, each fires as "speaker" in
|
||||
// separate conversation pairs (run_npc_conversations creates symmetric
|
||||
// pairs), so both directions are covered across two iterations.
|
||||
// Transfer is one-directional per NpcConversation component: speaker → partner.
|
||||
// When both participants have NpcConversation, each fires as "speaker"
|
||||
// in a separate query row, so both directions are covered.
|
||||
|
||||
let Ok([speaker_kg, mut partner_kg]) =
|
||||
kg_query.get_many_mut([speaker_entity, partner_entity])
|
||||
|
||||
Reference in New Issue
Block a user