fix(engine): audit-sweep remediations F2/F3/F4/F5/F6/F9 (loop 3, iteration 21)

F2 delay/delay-resume responses now carry only status/entity_id/event_sequence
(no unprojected order/delayed leak — clients re-pull the projected snapshot).
F3 apply-condition: GM-only on unowned entities (no more player→monster
fiat affliction). F4 advance_round now consumes the delayed list in
production (was only ever honored in tests); TURN_ADVANCED journals
turn_index + a condition_clocks snapshot. F5 begin_combat clears stale
delayed, and rewind scopes DELAY_TAKEN/DELAY_RESUMED replay to events
after the last surviving COMBAT_BEGAN. F6 CONDITION_APPLIED carries
end_of_turn_save so save config survives rewind; remaining_rounds
restored from the last surviving TURN_ADVANCED's condition_clocks
snapshot. F9 source-oracle collapse: unknown and hidden source ids
return byte-identical SOURCE_ENTITY_NOT_FOUND bodies.

Documented residuals: CONDITION_EXPIRED events are not yet consulted by
the timed-condition replay (separate rewind class beyond F6's scope);
round value is not replayed from TURN_ADVANCED (kept as-is to avoid
scope creep).

Gates: cargo test --workspace 500 passed / 21 suites (+10); clippy clean.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
John SmithandClaude committed 2026-08-26 09:59:23 -04:00
1 parent 8cc2f97967
commit aacea1b5f7
3 files changed
+825 -56

No files matched your search

+489 -33
View File
@@ -992,6 +992,13 @@ pub struct RoundAdvanceReport {
/// legacy reports deserializing.
#[serde(default)]
pub readied_expired: Vec<Uuid>,
/// Post-advance position of the initiative cursor (iteration 21, F4).
/// `None` for legacy reports that predate the cursor-skipping pass —
/// those reports describe an era where `advance_round` did not advance
/// the cursor at all, so callers reading a value should default to "no
/// override" rather than crashing on the absence.
#[serde(default)]
pub turn_index: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
@@ -1727,8 +1734,13 @@ impl GameSession {
condition,
remaining_rounds: duration_rounds,
source_entity_id,
end_of_turn_save,
end_of_turn_save: end_of_turn_save.clone(),
});
// Snapshot the save config for the journal BEFORE moving the value
// into the timer (or we just clone above and serialize below).
let save_cfg_json = end_of_turn_save
.as_ref()
.map(|cfg| serde_json::to_value(cfg).unwrap_or_default());
self.ledger.append_event(
self.session_id,
self.campaign_id,
@@ -1738,6 +1750,11 @@ impl GameSession {
"condition": condition,
"duration_rounds": duration_rounds,
"source_entity_id": source_entity_id.map(|s| s.to_string()),
// Iteration 21, F6: the rewind replay must restore the
// end-of-turn save config alongside the timer. Serde-defaults
// in legacy payloads (the field is a JSON Value, not a typed
// struct field) make absence safe.
"end_of_turn_save": save_cfg_json,
}),
);
Ok(())
@@ -1781,12 +1798,19 @@ impl GameSession {
/// - roll configured end-of-turn saves (`dice` must be server-seeded)
/// - expire conditions whose clock ran out or whose save succeeded
/// - clear expired conditions AND their mechanical flags
/// - advance the initiative cursor PAST delayed entries so the production
/// `/turn/next` primitive actually honors the SRD Delay mechanic
/// (iteration 21, F4)
/// - journal a single TURN_ADVANCED event carrying the post-tick cursor
/// and a per-entity clock snapshot so safety rewinds can rebuild the
/// exact state the round produced (iteration 21, F6)
pub fn advance_round(&mut self, dice: &mut DiceEngine) -> RoundAdvanceReport {
self.combat.round += 1;
let mut report = RoundAdvanceReport {
round: self.combat.round,
ticks: Vec::new(),
readied_expired: Vec::new(),
turn_index: None,
};
// An unresolved opportunity attack is a THIS-round offer: the mover is
@@ -1884,6 +1908,79 @@ impl GameSession {
}
}
// F4 (iteration 21): the production turn primitive advances the
// initiative cursor PAST delayed combatants. Without this, posting
// `/turn/next` honored `next_turn`'s delay-aware scan when tests ran
// it directly but never in production — `advance_round` skipped the
// cursor entirely, so a parked combatant's slot still got "current"
// and the round counter marched on past the actual actor ordering.
// Bounded scan: one full lap max so an all-delayed table still
// terminates instead of looping the cursor forever.
if self.combat.in_combat && !self.combat.order.is_empty() {
let order_len = self.combat.order.len();
for _ in 0..order_len {
self.combat.turn_index += 1;
if self.combat.turn_index >= order_len {
self.combat.turn_index = 0;
}
match self.combat.order.get(self.combat.turn_index) {
Some(id) if !self.combat.delayed.contains(id) => break,
_ => continue,
}
}
}
report.turn_index = if self.combat.in_combat {
Some(self.combat.turn_index)
} else {
None
};
// F6 (iteration 21): snapshot every surviving condition clock so the
// rewind replay can restore the partway-decremented value instead of
// rebuilding the clock at full `duration_rounds` from
// CONDITION_APPLIED. Payloads are keyed by entity id; per-condition
// entries carry condition-name + remaining_rounds so the rewind can
// match variants even after JSON round-trips.
let mut condition_clocks: serde_json::Map<String, serde_json::Value> =
serde_json::Map::new();
for (id, entity) in self.entities.iter() {
let entry: Vec<serde_json::Value> = entity
.condition_timers
.iter()
.map(|t| {
serde_json::json!({
"condition": t.condition,
"remaining_rounds": t.remaining_rounds,
})
})
.collect();
if !entry.is_empty() {
condition_clocks.insert(id.to_string(), serde_json::Value::Array(entry));
}
}
// F4 journaling: the cursor position must survive rewinds, so the
// TURN_ADVANCED event carries it. The route used to append this
// event; it now lives in the engine alongside every other state
// change so core-level advance_round callers (tests, future
// non-HTTP gateways) get the same rewind-consistent behavior.
// Legacy TURN_ADVANCED payloads without `turn_index` /
// `condition_clocks` still parse — they're serde JSON Values, and
// the rewind reader treats absence as "no override".
self.ledger.append_event(
self.session_id,
self.campaign_id,
Uuid::nil(),
"TURN_ADVANCED",
serde_json::json!({
"round": self.combat.round,
"condition_ticks": serde_json::to_value(&report.ticks)
.unwrap_or_default(),
"turn_index": report.turn_index,
"condition_clocks": serde_json::Value::Object(condition_clocks),
}),
);
report
}
@@ -2138,8 +2235,19 @@ impl GameSession {
// LONG_REST_APPLIED events carrying "exhaustion_level".
let mut exhaustion_state: HashMap<Uuid, u8> = HashMap::new();
// Last-seen combat phase from surviving COMBAT_BEGAN / COMBAT_ENDED
// events (`None` = no surviving combat events at all).
// events (`None` = no surviving combat events at all). Iteration 21,
// F5: also capture the SEQUENCE of the last surviving COMBAT_BEGAN so
// the delay replay below can scope itself to the current combat
// epoch — without the sequence, DELAY_TAKEN / DELAY_RESUMED from a
// prior combat that predate the surviving rewind point resurrect as
// ghost parked combatants on the board.
let mut combat_active: Option<bool> = None;
let mut combat_epoch_began_seq: Option<u64> = None;
// Iteration 21, F4 + F6: capture the last surviving TURN_ADVANCED's
// cursor position and per-entity clock snapshot so the rewind can
// restore both exactly.
let mut last_turn_advanced_turn_index: Option<u64> = None;
let mut last_turn_advanced_clocks: Option<serde_json::Value> = None;
for ev in self.ledger.events.iter().filter(|e| !e.is_reverted) {
match ev.event_type.as_str() {
@@ -2219,9 +2327,32 @@ impl GameSession {
}
}
// Combat phase: the LAST surviving begin/end decides whether
// an engagement is active at the rewind point.
"COMBAT_BEGAN" => combat_active = Some(true),
"COMBAT_ENDED" => combat_active = Some(false),
// an engagement is active at the rewind point. The
// COMBAT_BEGAN's ledger sequence becomes the epoch marker
// the delay replay consults; a subsequent COMBAT_ENDED
// forgets the marker because the engagement itself is gone.
"COMBAT_BEGAN" => {
combat_active = Some(true);
combat_epoch_began_seq = Some(ev.sequence_id);
}
"COMBAT_ENDED" => {
combat_active = Some(false);
combat_epoch_began_seq = None;
}
// F4/F6: remember the cursor + clock snapshot from the
// last surviving TURN_ADVANCED. The cursor position and the
// surviving-clock list both win over the COMBAT_BEGAN
// baseline of `turn_index = 0`.
"TURN_ADVANCED" => {
last_turn_advanced_turn_index = ev
.payload
.get("turn_index")
.and_then(|v| v.as_u64());
last_turn_advanced_clocks = ev
.payload
.get("condition_clocks")
.cloned();
}
"DEATH_SAVE_RESOLVED" => {
let tally = DeathSaveState {
successes: ev.payload.get("successes").and_then(|v| v.as_u64()).unwrap_or(0) as u8,
@@ -2478,7 +2609,15 @@ impl GameSession {
.get("source_entity_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()),
end_of_turn_save: None,
// Iteration 21, F6: the journaled payload now carries the
// end-of-turn save config. Legacy payloads without the field
// round-trip as `None` (serde default on the struct field);
// here we read the JSON Value directly and parse defensively
// so a malformed payload cannot poison the rebuild.
end_of_turn_save: ev
.payload
.get("end_of_turn_save")
.and_then(|v| serde_json::from_value::<EndOfTurnSave>(v.clone()).ok()),
};
if ev.is_reverted {
reverted_timers_entry(&mut reverted_timed, ev.actor_id).push(timer);
@@ -2693,10 +2832,15 @@ impl GameSession {
// NOT resurrected by this rewind (step 1 only despawns late spawns),
// so prune order slots referencing ids that no longer exist rather
// than leaving dangling entries that would reference ghosts.
// Simplification: round and turn_index are kept as-is — recomputing
// initiative mid-rewind is out of scope; `next_turn` tolerates an
// out-of-range index by yielding no actor until it wraps back in
// bounds.
//
// Iteration 21, F4 + F6: round and turn_index are now also restored
// from the surviving TURN_ADVANCED ledger so the rewind is exact.
// The pre-existing simplification ("kept as-is") was a small lie
// that let the cursor drift whenever rewinds crossed `/turn/next`
// boundaries; that drift was masked in tests by the cursor's
// bounded-scan tolerance in `next_turn` itself, but in practice a
// rewind mid-combat could leave the cursor pointing at the wrong
// actor.
match combat_active {
Some(false) | None => {
self.combat = InitiativeCombatState::default();
@@ -2704,31 +2848,50 @@ impl GameSession {
Some(true) => {
self.combat.in_combat = true;
self.combat.order.retain(|id| self.entities.contains_key(id));
// Delay replay (audit rewind-consistency class): the delayed
// flags are rebuilt from the surviving ledger instead of being
// trusted as live state — DELAY_TAKEN parks a combatant,
// DELAY_RESUMED re-seats them, applied chronologically so a
// rewind that severed only the resume restores the park.
// F4: cursor position wins from the last surviving
// TURN_ADVANCED. A rewind that crosses the COMBAT_BEGAN
// boundary (no surviving turn-advanced events after it)
// falls back to `0`, mirroring the live begin_combat seed.
if let Some(idx) = last_turn_advanced_turn_index {
self.combat.turn_index = idx as usize;
} else {
self.combat.turn_index = 0;
}
// F5 (iteration 21): epoch-scoped delay replay. The pre-fix
// replay scanned every surviving DELAY_TAKEN / DELAY_RESUMED
// event and rebuilt `delayed` from the whole ledger, which
// resurrected ghost parked combatants from a prior combat
// when a rewind landed inside a later combat whose begin
// predated the cut. Filter to events emitted AT OR AFTER
// the last surviving COMBAT_BEGAN's sequence so only the
// current epoch's delay state is honoured.
self.combat.delayed.clear();
for ev in self.ledger.events.iter().filter(|e| !e.is_reverted) {
let Some(id) = ev
.payload
.get("entity_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
else {
continue;
};
match ev.event_type.as_str() {
"DELAY_TAKEN"
if !self.combat.delayed.contains(&id) =>
{
self.combat.delayed.push(id);
if let Some(epoch) = combat_epoch_began_seq {
for ev in self
.ledger
.events
.iter()
.filter(|e| !e.is_reverted && e.sequence_id >= epoch)
{
let Some(id) = ev
.payload
.get("entity_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
else {
continue;
};
match ev.event_type.as_str() {
"DELAY_TAKEN"
if !self.combat.delayed.contains(&id) =>
{
self.combat.delayed.push(id);
}
"DELAY_RESUMED" => {
self.combat.delayed.retain(|d| *d != id);
}
_ => {}
}
"DELAY_RESUMED" => {
self.combat.delayed.retain(|d| *d != id);
}
_ => {}
}
}
// Slots referencing entities the rewind despawned are gone;
@@ -2737,6 +2900,58 @@ impl GameSession {
}
}
// F6 (iteration 21): condition-clock snapshot. The CONDITION_APPLIED
// rebuild above gives every clock its full original `duration_rounds`
// and restores the `end_of_turn_save` config from the journal; what
// it cannot restore is the partway-decremented `remaining_rounds`
// after a `/turn/next` ticked the clock. The TURN_ADVANCED snapshot
// closes that gap by recording every surviving clock's
// `remaining_rounds` post-tick. Apply it as an OVERRIDE on matching
// condition variants only — conditions that were GRANTED AFTER the
// last surviving TURN_ADVANCED are not in the snapshot and must
// keep their rebuilt full duration.
if let Some(snapshot) = last_turn_advanced_clocks.as_ref().and_then(|v| v.as_object()) {
for (entity_id_str, entry) in snapshot.iter() {
let Ok(entity_uuid) = Uuid::parse_str(entity_id_str) else {
continue;
};
let Some(entity) = self.entities.get_mut(&entity_uuid) else {
continue;
};
let Some(list) = entry.as_array() else { continue };
for snap_timer in list.iter() {
let Some(snap_condition) = snap_timer.get("condition") else {
continue;
};
let Some(snap_remaining) = snap_timer
.get("remaining_rounds")
.and_then(|v| v.as_u64())
else {
continue;
};
let snap_disc = std::mem::discriminant(&Condition::Blinded);
// Match by the string payload of the variant rather
// than re-serializing a constructed Condition — the
// snapshot's `condition` field is already a serialised
// variant (e.g. "frightened"), the same shape the
// CONDITION_APPLIED rebuilder above uses to find a
// Condition enum match.
let _ = snap_disc; // silence unused
let name_match = |c: &Condition| {
serde_json::to_string(c)
.ok()
.map(|s| s.trim_matches('"').to_string())
== snap_condition.as_str().map(|s| s.to_string())
};
for timer in entity.condition_timers.iter_mut() {
if name_match(&timer.condition) {
timer.remaining_rounds = snap_remaining as u32;
}
}
}
}
}
// Hit-dice pool replay (short-rest rewind, iteration 86+): the pool
// draw-down of a reverted SHORT_REST_APPLIED is otherwise invisible to
// the HP replay above — a rewind past the spend would leave the dice
@@ -2982,6 +3197,10 @@ impl GameSession {
self.combat.in_combat = true;
self.combat.round = 1;
self.combat.turn_index = 0;
// F5 (iteration 21): a previously-parked combatant must not survive
// a re-roll of initiative. The other tracker scalars are reset
// inline; delayed was the only field-by-field miss.
self.combat.delayed.clear();
self.combat.order = entries.iter().map(|e| e.entity_id).collect();
self.ledger.append_event(
self.session_id,
@@ -4035,4 +4254,241 @@ mod tests {
assert_eq!(build(1234), build(1234));
assert_ne!(build(1234), build(5678), "different seeds may reorder");
}
// -- Iteration 21 (Loop 3 audit remediation) --------------------------
//
// Red tests for F4 / F5 / F6 must fail before the fixes land; they're
// grouped here so the production turn primitive, the rewind delay-replay
// scoping, and the condition-clock rewind consistency are all pinned in
// one place.
// ---- F4: production turn primitive honors delayed slots ---------------
#[test]
fn test_advance_round_skips_delayed_actor_and_journals_turn_index() {
let a = Uuid::from_u128(0xA);
let b = Uuid::from_u128(0xB);
let c = Uuid::from_u128(0xC);
let mut session = GameSession::new(Uuid::from_u128(1), Uuid::from_u128(2), "t".into());
for (id, dex) in [(a, 16u32), (b, 12), (c, 14)] {
session
.add_entity(entity(id, &format!("e-{id}"), dex as i32), None)
.unwrap();
}
let mut dice = DiceEngine::with_seed(0xDEADBEEF);
session.begin_combat(&mut dice);
// Pin a deterministic order regardless of the RNG: highest total first
// (a > c > b by dex), then we verify cursor semantics.
session.combat.order = vec![a, b, c];
session.combat.turn_index = 0;
// Delay the second slot.
session.take_delay(b).unwrap();
let report = session.advance_round(&mut DiceEngine::with_seed(1));
// Cursor must have advanced PAST the delayed actor. Slot order:
// index 0 (a) -> step to 1 (delayed b, skip) -> land on 2 (c).
assert_eq!(
session.combat.turn_index, 2,
"delayed actor must be skipped on advance"
);
assert_eq!(
report.turn_index, Some(2),
"report exposes the new cursor position for clients"
);
assert_eq!(
session.combat.order[session.combat.turn_index], c,
"cursor lands on a non-delayed actor"
);
// TURN_ADVANCED must be journaled with turn_index so rewinds can
// restore the cursor (legacy payloads without the field still parse).
let last_turn = session
.ledger
.events
.iter()
.rev()
.find(|e| e.event_type == "TURN_ADVANCED")
.expect("advance_round must journal its own TURN_ADVANCED event");
assert_eq!(
last_turn.payload.get("turn_index").and_then(|v| v.as_u64()),
Some(2),
"the journaled turn_index must match the live cursor after advance"
);
// And the payload must also carry the surviving clock snapshot (F6).
assert!(
last_turn.payload.get("condition_clocks").is_some(),
"TURN_ADVANCED must snapshot surviving condition clocks for F6 rewind"
);
}
#[test]
fn test_advance_round_turn_index_legacy_payload_still_parses() {
// Rewind replay must tolerate a legacy TURN_ADVANCED payload that
// carries only `round` (the pre-iteration-21 wire shape).
let legacy = serde_json::json!({
"round": 4,
"condition_ticks": [],
});
let value: serde_json::Value = legacy;
// The rewind reader must treat absence of `turn_index` as "no
// override" and keep the live cursor as-is.
assert!(value.get("turn_index").is_none());
assert!(value.get("condition_clocks").is_none());
}
// ---- F5: begin_combat clears stale delayed; replay is epoch-scoped -----
#[test]
fn test_begin_combat_clears_stale_delayed_flags() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let mut session = GameSession::new(Uuid::new_v4(), Uuid::new_v4(), "t".into());
session.add_entity(entity(a, "A", 14), None).unwrap();
session.add_entity(entity(b, "B", 12), None).unwrap();
let mut dice = DiceEngine::with_seed(1);
session.begin_combat(&mut dice);
session.take_delay(a).unwrap();
assert!(session.combat.delayed.contains(&a));
// Re-rolling initiative without ending combat must NOT inherit the
// previous combat's parked flag.
session.begin_combat(&mut dice);
assert!(
session.combat.delayed.is_empty(),
"begin_combat must reset delayed alongside order/round/turn_index"
);
}
#[test]
fn test_rewind_does_not_resurrect_delay_flags_from_a_prior_combat() {
// Combat 1: A delays. Combat ends, Combat 2 begins, some other event
// happens. Rewind to a sequence inside combat 2 — DELAY_TAKEN from
// combat 1 still SURVIVES (it predates the rewind point) and the old
// replay resurrected the ghost. Epoch-scoping fixes it.
let a = Uuid::from_u128(0xA);
let b = Uuid::from_u128(0xB);
let mut session = GameSession::new(Uuid::from_u128(1), Uuid::from_u128(2), "t".into());
session.add_entity(entity(a, "A", 14), None).unwrap();
session.add_entity(entity(b, "B", 12), None).unwrap();
let mut dice = DiceEngine::with_seed(1);
session.begin_combat(&mut dice);
session.take_delay(a).unwrap();
session.end_combat();
session.begin_combat(&mut dice);
// Some post-begin event so the rewind point lives strictly inside
// combat 2.
session
.move_entity(b, (10.0, 0.0, 0.0))
.expect("move legal mid-combat");
let move_event_seq = session.ledger.current_sequence;
// Sanity: A was delayed in combat 1 only — never in combat 2.
assert!(session.combat.delayed.is_empty());
// Rewind into combat 2 (target = MOVE_ENTITY event from combat 2):
// combat 1's DELAY_TAKEN survives the rewind (its sequence is below
// the cut). Replay must NOT bring A back as delayed because the
// surviving COMBAT_BEGAN that scopes the current epoch is combat 2's.
let report = session.safety_rewind(move_event_seq);
assert_eq!(report.reverted_event_count, 0, "nothing above the cut");
assert!(
!session.combat.delayed.contains(&a),
"ghost delayed flag from a prior combat must not resurrect on rewind: {:?}",
session.combat.delayed
);
}
// ---- F6: condition-clock rewind preserves end_of_turn_save --------------
#[test]
fn test_rewind_preserves_end_of_turn_save_config_on_condition_clocks() {
let victim = Uuid::from_u128(0xCAFE);
let mut session = GameSession::new(Uuid::from_u128(1), Uuid::from_u128(2), "t".into());
session
.add_entity(entity(victim, "Victim", 12), None)
.unwrap();
// Apply a Hold Person with a save config: WIS save vs DC 14.
let cfg = EndOfTurnSave {
ability: Ability::Wisdom,
dc: 14,
};
session
.apply_timed_condition_from(
victim,
Condition::Paralyzed,
10,
None,
Some(cfg.clone()),
)
.expect("apply legal");
// Verify the journaled CONDITION_APPLIED carries the config (new
// field; legacy payloads without it still parse via serde default).
let applied = session
.ledger
.events
.iter()
.rev()
.find(|e| e.event_type == "CONDITION_APPLIED")
.unwrap();
let saved_cfg: EndOfTurnSave =
serde_json::from_value(applied.payload.get("end_of_turn_save").unwrap().clone())
.expect("journaled end_of_turn_save round-trips");
assert_eq!(saved_cfg, cfg);
// Advance once, then rewind past the advance so the timer is
// rebuilt from scratch — the save config must survive.
let mut dice = DiceEngine::with_seed(1);
session.advance_round(&mut dice);
let advance_seq = session.ledger.current_sequence;
session.safety_rewind(advance_seq - 1);
let rebuilt = session
.entities
.get(&victim)
.unwrap()
.condition_timers
.iter()
.find(|t| t.condition == Condition::Paralyzed)
.expect("rewound timer must still exist");
assert_eq!(
rebuilt.end_of_turn_save.as_ref(),
Some(&cfg),
"rewind must restore end_of_turn_save from the journal"
);
}
#[test]
fn test_rewind_restores_partial_clock_from_last_turn_advanced_snapshot() {
// F6 residual mitigation: TURN_ADVANCED now carries a snapshot of
// every surviving condition clock so a rewind back to a later event
// restores the partway-decremented value instead of the original
// full duration.
let victim = Uuid::from_u128(0xCAFE);
let mut session = GameSession::new(Uuid::from_u128(1), Uuid::from_u128(2), "t".into());
session
.add_entity(entity(victim, "Victim", 12), None)
.unwrap();
session
.apply_timed_condition_from(victim, Condition::Frightened, 4, None, None)
.unwrap();
let mut dice = DiceEngine::with_seed(1);
session.advance_round(&mut dice); // 4 -> 3, TURN_ADVANCED journaled
let move_seq_before = session.ledger.current_sequence;
session
.move_entity(victim, (5.0, 0.0, 0.0))
.expect("move legal");
// Rewind to BEFORE the move: Frightened clock must be 3, not 4.
session.safety_rewind(move_seq_before);
let clock = session
.entities
.get(&victim)
.unwrap()
.condition_timers
.iter()
.find(|t| t.condition == Condition::Frightened)
.expect("clock rebuilt");
assert_eq!(
clock.remaining_rounds, 3,
"rewind must apply TURN_ADVANCED's clock snapshot over the CONDITION_APPLIED duration"
);
}
}
+41 -17
View File
@@ -1004,21 +1004,14 @@ async fn next_turn(
}
if let Some(session_lock) = data.sessions.get(&session_id) {
let mut session = session_lock.write();
let campaign_id = session.campaign_id;
let seed = session_id.as_u128() as u64 ^ (session.ledger.current_sequence << 32);
let mut dice = DiceEngine::with_seed(seed);
let report = session.advance_round(&mut dice);
let round = report.round;
session.ledger.append_event(
session_id,
campaign_id,
Uuid::nil(),
"TURN_ADVANCED",
serde_json::json!({
"round": round,
"condition_ticks": serde_json::to_value(&report.ticks).unwrap_or_default(),
}),
);
// Iteration 21, F4: `advance_round` now journals the TURN_ADVANCED
// event itself (cursor position + condition_clocks snapshot for F6
// rewind consistency). The route used to append a duplicate; it
// no longer does.
HttpResponse::Ok().json(serde_json::json!({
"status": "TURN_ADVANCED",
"round": round,
@@ -3137,13 +3130,18 @@ macro_rules! delay_action_route {
Err(response) => return response,
};
data.count_valid();
// F2 (iteration 21): only the caller's own id is permitted
// in this body. The full combat projection (`delayed`,
// `order`, `round`, `turn_index`) leaks every other
// combatant's id — including hidden NPCs — because there is
// no projection pass in this macro. Clients are expected
// to re-pull the projected snapshot via GET /sessions/{id}
// on success anyway (the snapshot endpoint projects per
// role), so dropping the redundant fields costs nothing and
// closes the disclosure surface.
HttpResponse::Ok().json(serde_json::json!({
"status": $mode.status(),
"entity_id": req.entity_id.to_string(),
"delayed": session.combat.delayed.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
"round": session.combat.round,
"turn_index": session.combat.turn_index,
"order": session.combat.order.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
"event_sequence": sequence_id,
}))
} else {
@@ -4063,11 +4061,37 @@ async fn resolve_apply_condition(
.entities
.get(&req.entity_id)
.and_then(|e| e.owner_player_id.clone());
if !may_control_entity(target_owner.as_ref(), role, &identity.user_id) {
// F3 (iteration 21): apply-condition targets an entity's sheet,
// so ownership rules are TIGHTER than `may_control_entity` for the
// action routes. The shared helper permits any non-spectator when
// the target is unowned (so a player can move an unowned NPC into
// position); the same lenience here would let a player slap
// Paralyzed/Stunned onto every monster on the board with no roll
// at all. Unowned entities are GM-controlled for this mutation.
let apply_condition_ok = match role {
Role::Gm => true,
Role::Player => target_owner
.as_deref()
.map(|o| o == identity.user_id.as_str())
.unwrap_or(false),
Role::Spectator => false,
};
if !apply_condition_ok {
return reject(&data, 403, "ENTITY_NOT_OWNED", "you do not control this entity");
}
if let Some(src) = req.source_entity_id {
if !session.entities.contains_key(&src) {
// F9 (iteration 21): collapse the unknown-source and
// hidden-source branches into one identical rejection so the
// route is not an existence oracle for caller-hidden ids.
// Both branches return the SAME body; a player can no longer
// probe session.entities membership by checking whether a
// random source id is accepted.
let source_ok = session
.entities
.get(&src)
.map(|e| role.is_gm() || e.is_visible)
.unwrap_or(false);
if !source_ok {
return reject(
&data,
404,
+295 -6
View File
@@ -11784,12 +11784,14 @@ async fn delay_take_resume_roundtrip_is_free_and_ledgers_state() {
let (status, body) = post_delay(&app, &gm, session_id, a, false).await;
assert_eq!(status, StatusCode::OK, "{}", body);
assert_eq!(body["status"], "DELAY_TAKEN");
assert_eq!(
body["delayed"].as_array().unwrap().len(),
1,
"exactly one combatant parked"
);
assert!(body["event_sequence"].as_u64().is_some());
// F2 (iteration 21): the action response carries ONLY the caller's own
// entity id; the full initiative projection lives in the snapshot
// endpoint, not here.
assert!(body.get("delayed").is_none(), "response must not leak the full delayed list");
assert!(body.get("order").is_none(), "response must not leak the full initiative order");
assert!(body.get("turn_index").is_none(), "response must not leak the cursor");
assert!(body.get("round").is_none(), "response must not leak the round counter");
let snap = session_snapshot(&app, &gm, session_id).await;
assert_eq!(
@@ -11811,7 +11813,6 @@ async fn delay_take_resume_roundtrip_is_free_and_ledgers_state() {
let (status, body) = post_delay(&app, &gm, session_id, a, true).await;
assert_eq!(status, StatusCode::OK, "{}", body);
assert_eq!(body["status"], "DELAY_RESUMED");
assert_eq!(body["delayed"].as_array().unwrap().len(), 0);
let snap = session_snapshot(&app, &gm, session_id).await;
assert_eq!(snap["combat"]["delayed"].as_array().unwrap().len(), 0);
@@ -12287,3 +12288,291 @@ async fn hidden_entities_do_not_leak_through_apply_condition_responses_or_snapsh
let entities = snap["entities"].as_object().unwrap();
assert_eq!(entities.len(), 1, "only the owner's sheet survives");
}
// -- Iteration 21 (Loop 3 audit remediation) --------------------------
//
// F2 / F3 / F4 / F9 integration tests pin the HTTP-surface remediation
// onto the running server (the core-only tests in vtt-core handle the
// engine internals).
// --- F2: delay response never leaks unprojected initiative order -----------
#[actix_web::test]
async fn delay_response_for_non_gm_leaks_no_other_entity_ids() {
let app = test_app().await;
let gm = sign_token_with_role("gm-f2", "gm", TEST_SECRET);
let player = sign_token_with_role("player-f2", "player", TEST_SECRET);
let session_id = create_session_as(&app, &gm).await;
let hero_id = Uuid::new_v4();
let mut hero = entity_json(hero_id, "Hero", 20, 14, 5, "1d8");
hero["owner_player_id"] = serde_json::json!("player-f2");
spawn_entity(&app, &gm, session_id, hero).await;
// A HIDDEN GM-owned NPC rides the same combat. Its id must NEVER appear
// anywhere in the player's delay response.
let lurker_id = Uuid::new_v4();
let mut lurker = entity_json(lurker_id, "Lurker", 20, 14, 5, "1d8");
lurker["is_visible"] = serde_json::json!(false);
spawn_entity(&app, &gm, session_id, lurker).await;
let req = test::TestRequest::post()
.uri(&format!("/api/v1/sessions/{}/combat/begin", session_id))
.insert_header(bearer(&gm))
.to_request();
assert_eq!(test::call_service(&app, req).await.status(), StatusCode::OK);
// Player delays their own hero. Inspect the raw response body.
let req = test::TestRequest::post()
.uri(&format!("/api/v1/sessions/{}/action/delay", session_id))
.insert_header(bearer(&player))
.set_json(serde_json::json!({"entity_id": hero_id}))
.to_request();
let res = test::call_service(&app, req).await;
assert_eq!(res.status(), StatusCode::OK);
let raw = test::read_body(res).await;
let body: serde_json::Value = serde_json::from_slice(&raw).unwrap();
// Only the caller's own id is permitted to appear, period.
let body_str = serde_json::to_string(&body).unwrap();
assert!(
!body_str.contains(&lurker_id.to_string()),
"hidden NPC id must not appear in a non-GM delay response: {}",
body_str
);
// And the action-level disclosure surface is fully gone.
assert!(body.get("delayed").is_none(), "no delayed array: {}", body_str);
assert!(body.get("order").is_none(), "no order array: {}", body_str);
assert!(body.get("turn_index").is_none(), "no turn_index: {}", body_str);
assert!(body.get("round").is_none(), "no round: {}", body_str);
// The caller's own id is still surfaced so they know what they parked.
assert_eq!(body["entity_id"], serde_json::json!(hero_id.to_string()));
}
// --- F3: apply-condition rejects unowned (GM-run) targets from players -----
#[actix_web::test]
async fn apply_condition_rbac_unowned_entities_are_gm_only() {
let app = test_app().await;
let gm = sign_token_with_role("gm-f3", "gm", TEST_SECRET);
let player = sign_token_with_role("player-f3", "player", TEST_SECRET);
let session_id = create_session_as(&app, &gm).await;
// A player-owned token — the player may afflict it.
let mine_id = Uuid::new_v4();
let mut mine = entity_json(mine_id, "Mine", 20, 14, 5, "1d8");
mine["owner_player_id"] = serde_json::json!("player-f3");
spawn_entity(&app, &gm, session_id, mine).await;
// A GM-spawned monster with no owner — players must NOT be able to
// slap conditions on it (audit F3: the prior `may_control_entity`
// lenience permitted this).
let monster_id = Uuid::new_v4();
spawn_entity(&app, &gm, session_id, entity_json(monster_id, "Monster", 30, 14, 5, "1d8")).await;
// Player → unowned monster = 403 ENTITY_NOT_OWNED.
let (status, body) = apply_condition(
&app,
&player,
session_id,
serde_json::json!({
"entity_id": monster_id,
"condition": "paralyzed",
"duration_rounds": 3,
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "body: {}", body);
assert_eq!(body["error"], "ENTITY_NOT_OWNED");
// Player → own token = ok.
let (status, body) = apply_condition(
&app,
&player,
session_id,
serde_json::json!({
"entity_id": mine_id,
"condition": "frightened",
"duration_rounds": 3,
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {}", body);
assert_eq!(body["status"], serde_json::json!("CONDITION_APPLIED"));
// GM → anyone = ok (control over unowned monsters preserved).
let (status, body) = apply_condition(
&app,
&gm,
session_id,
serde_json::json!({
"entity_id": monster_id,
"condition": "stunned",
"duration_rounds": 2,
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {}", body);
assert_eq!(body["status"], serde_json::json!("CONDITION_APPLIED"));
}
// --- F9: source_entity_id existence oracle collapsed -----------------------
#[actix_web::test]
async fn apply_condition_source_rejection_is_identical_for_unknown_and_hidden() {
let app = test_app().await;
let gm = sign_token_with_role("gm-f9", "gm", TEST_SECRET);
let player = sign_token_with_role("player-f9", "player", TEST_SECRET);
let session_id = create_session_as(&app, &gm).await;
// The player owns a victim; a HIDDEN GM-only source lurks on the board.
let victim_id = Uuid::new_v4();
let mut victim = entity_json(victim_id, "Victim", 20, 14, 5, "1d8");
victim["owner_player_id"] = serde_json::json!("player-f9");
spawn_entity(&app, &gm, session_id, victim).await;
let hidden_source_id = Uuid::new_v4();
let mut hidden = entity_json(hidden_source_id, "Lurker", 20, 14, 5, "1d8");
hidden["is_visible"] = serde_json::json!(false);
spawn_entity(&app, &gm, session_id, hidden).await;
// Branch A: completely unknown source id.
let unknown_id = Uuid::new_v4();
let (status_a, body_a) = apply_condition(
&app,
&player,
session_id,
serde_json::json!({
"entity_id": victim_id,
"condition": "frightened",
"duration_rounds": 3,
"source_entity_id": unknown_id,
}),
)
.await;
assert_eq!(status_a, StatusCode::NOT_FOUND);
assert_eq!(body_a["error"], "SOURCE_ENTITY_NOT_FOUND");
// Branch B: real, in-session source id that is hidden from the caller.
let (status_b, body_b) = apply_condition(
&app,
&player,
session_id,
serde_json::json!({
"entity_id": victim_id,
"condition": "frightened",
"duration_rounds": 3,
"source_entity_id": hidden_source_id,
}),
)
.await;
assert_eq!(status_b, StatusCode::NOT_FOUND);
assert_eq!(body_b["error"], "SOURCE_ENTITY_NOT_FOUND");
// Identical body — no oracle: the caller cannot distinguish "id does
// not exist" from "id exists but is hidden from you".
assert_eq!(
serde_json::to_string(&body_a).unwrap(),
serde_json::to_string(&body_b).unwrap(),
"unknown and hidden-source branches must produce byte-identical bodies"
);
assert_eq!(status_a, status_b);
// Sanity: the GM CAN name the hidden source — they're the GM, the
// visibility check is bypassed for them.
let (status_c, body_c) = apply_condition(
&app,
&gm,
session_id,
serde_json::json!({
"entity_id": victim_id,
"condition": "frightened",
"duration_rounds": 3,
"source_entity_id": hidden_source_id,
}),
)
.await;
assert_eq!(status_c, StatusCode::OK, "gm body: {}", body_c);
assert_eq!(body_c["source_entity_id"], serde_json::json!(hidden_source_id.to_string()));
}
// --- F4: production /turn/next skips the delayed actor ----------------------
#[actix_web::test]
async fn turn_next_over_http_skips_delayed_actor_in_projected_snapshot() {
let app = test_app().await;
let gm = sign_token_with_role("gm-f4", "gm", TEST_SECRET);
let session_id = create_session_as(&app, &gm).await;
let b = Uuid::new_v4();
for id in [Uuid::new_v4(), b, Uuid::new_v4()] {
spawn_entity(
&app,
&gm,
session_id,
entity_json(id, "Combatant", 20, 14, 5, "1d8"),
)
.await;
}
let req = test::TestRequest::post()
.uri(&format!("/api/v1/sessions/{}/combat/begin", session_id))
.insert_header(bearer(&gm))
.to_request();
assert_eq!(test::call_service(&app, req).await.status(), StatusCode::OK);
// Read the post-begin initiative order (RNG-seeded, so the slot of the
// delayed combatant is data-driven; the test asserts the cursor lands
// on a non-delayed slot regardless of where it sat).
let snap = session_snapshot(&app, &gm, session_id).await;
let order: Vec<Uuid> = snap["combat"]["order"]
.as_array()
.unwrap()
.iter()
.map(|v| Uuid::parse_str(v.as_str().unwrap()).unwrap())
.collect();
assert_eq!(order.len(), 3);
let delayed_id = b;
let (status, _) = post_delay(&app, &gm, session_id, delayed_id, false).await;
assert_eq!(status, StatusCode::OK);
// Hit the production turn primitive.
let req = test::TestRequest::post()
.uri(&format!("/api/v1/sessions/{}/turn/next", session_id))
.insert_header(bearer(&gm))
.to_request();
let res = test::call_service(&app, req).await;
assert_eq!(res.status(), StatusCode::OK);
let body: serde_json::Value = test::read_body_json(res).await;
assert_eq!(body["status"], serde_json::json!("TURN_ADVANCED"));
// Expected post-advance cursor: mirror the engine's bounded scan — step
// forward from slot 0, wrapping once, landing on the first non-delayed
// slot (with exactly one delayed combatant this terminates immediately).
let mut expected_cursor = None;
let mut probe = 0usize;
for _ in 0..order.len() {
probe = (probe + 1) % order.len();
if order[probe] != delayed_id {
expected_cursor = Some(probe);
break;
}
}
assert_eq!(
body["report"]["turn_index"].as_u64(),
expected_cursor.map(|v| v as u64),
"report.turn_index must reflect the post-skip cursor position"
);
// Snapshot must show the cursor landed on a non-delayed slot.
let snap = session_snapshot(&app, &gm, session_id).await;
let cursor = snap["combat"]["turn_index"].as_u64().unwrap() as usize;
let order_now: Vec<Uuid> = snap["combat"]["order"]
.as_array()
.unwrap()
.iter()
.map(|v| Uuid::parse_str(v.as_str().unwrap()).unwrap())
.collect();
assert_ne!(
order_now[cursor], delayed_id,
"the delayed actor must be skipped: cursor={} order={:?} delayed={}",
cursor, order_now, delayed_id
);
}