mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-07 10:45:58 -10:00
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:
1 parent
8cc2f97967
commit
aacea1b5f7
3 files changed
+825
-56
No files matched your search
+489
-33
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user