mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-07 10:45:58 -10:00
fix(engine): Exhaustion tuple-variant clocks survive rewind replay (loop 3, iteration 30)
F4 from audit 2. Externally-tagged serde renders the enum's only tuple
variant Condition::Exhaustion(u8) as an object {"exhaustion": <level>}
whose .as_str() is None, so TWO F6 sites silently skipped it: the
CONDITION_APPLIED rebuild never re-armed an exhaustion timer (journal-
invisible), and the TURN_ADVANCED name_match never restored recorded
remaining_rounds (rewinding snapped an exhausted character back to its
pre-decrement full level). Repaired both — the reproduction test first
failed with left:4 right:3 (the literal symptom), green after the
name_match fix. Branches documented: legacy payload lacking the field
matches nothing; a differing snapshot level cross-checks level equality;
the payload's recorded level is authoritative.
Hardening survey: the documented F6 residual (conditions expired before
the rewind point resurrect from the CONDITION_APPLIED-only rebuild)
confirmed empirically, and deliberately NOT force-fixed — the snapshot
writer omits zero-clock entities and within-entity absent conditions, so
an alive-set prune would be lossy; the exact limitation is documented
in the F6 comment block for a future writer-side change.
(Also rides along the purge lane's verify_entity_conservation removal,
which shared this file with iteration 27.)
Gates: cargo test --workspace 504 passed / 21 suites; clippy clean.
Co-Authored-By: Claude <[email protected]>
This commit is contained in:
1 parent
d788f4ba14
commit
d3f5130ba0
1 file changed
+192
-45
+192
-45
@@ -2569,32 +2569,7 @@ impl GameSession {
|
||||
if ev.event_type != "CONDITION_APPLIED" {
|
||||
continue;
|
||||
}
|
||||
let Some(condition) = ev
|
||||
.payload
|
||||
.get("condition")
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(|name| {
|
||||
[
|
||||
Condition::Blinded,
|
||||
Condition::Charmed,
|
||||
Condition::Deafened,
|
||||
Condition::Frightened,
|
||||
Condition::Incapacitated,
|
||||
Condition::Invisible,
|
||||
Condition::Paralyzed,
|
||||
Condition::Petrified,
|
||||
Condition::Poisoned,
|
||||
Condition::Prone,
|
||||
Condition::Restrained,
|
||||
Condition::Stunned,
|
||||
Condition::Unconscious,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|rep| {
|
||||
serde_json::to_string(rep).unwrap_or_default().trim_matches('"') == name
|
||||
})
|
||||
})
|
||||
else {
|
||||
let Some(condition) = parse_timed_condition(ev.payload.get("condition")) else {
|
||||
continue;
|
||||
};
|
||||
let timer = ConditionTimer {
|
||||
@@ -2910,6 +2885,28 @@ impl GameSession {
|
||||
// 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.
|
||||
//
|
||||
// Hardening-pass survey (iteration 30, F4 lane) — deliberately NOT an
|
||||
// alive-set prune: the CONDITION_APPLIED-only rebuild above can
|
||||
// resurrect a condition whose CONDITION_EXPIRED event predates the
|
||||
// rewind point (rewinding to a point after the expiry reapplies the
|
||||
// expired clock at full duration). Treating the last surviving
|
||||
// TURN_ADVANCED snapshot as authoritative for "what's alive" would fix
|
||||
// that, but the snapshot cannot express the alive-set:
|
||||
// * the writer omits any entity with zero surviving clocks
|
||||
// (`if !entry.is_empty()`), so "entity absent" is ambiguous between
|
||||
// "no timers" and "entity not in `self.entities` at snapshot time"
|
||||
// (spawned later, or the snapshot predates it). A prune cannot
|
||||
// know a fully-expired entity's alive-set is empty.
|
||||
// * within a present entity, an absent condition is ambiguous between
|
||||
// "expired before the snapshot" (prune) and "granted after the
|
||||
// snapshot" (must keep full duration — the documented post-TA-grant
|
||||
// caveat above).
|
||||
// So the residual is left documented rather than fixed with a lossy
|
||||
// prune; closing it needs the snapshot to carry a per-entity alive-set
|
||||
// (e.g. always emit an entry, including an explicit empty list, and
|
||||
// distinguish grant-after- from expired-before), which is a writer
|
||||
// change and out of scope for this reader lane.
|
||||
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 {
|
||||
@@ -2929,19 +2926,40 @@ impl GameSession {
|
||||
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())
|
||||
// Match the snapshot's `condition` field back to a timer's
|
||||
// condition by comparing the timer against the serialised
|
||||
// variant shape, without re-serializing a constructed
|
||||
// Condition. Unit variants serialise as a bare snake-case
|
||||
// string ("frightened") and match by name; the sole tuple
|
||||
// variant, Condition::Exhaustion(u8), serialises as an
|
||||
// object {"exhaustion": <level>} whose `.as_str()` is None,
|
||||
// so it is matched by its numeric payload instead.
|
||||
// Iteration 30 (F4): without the Exhaustion branch the
|
||||
// recorded remaining_rounds was never restored and an
|
||||
// exhausted creature rewound to its full pre-decrement
|
||||
// duration.
|
||||
let name_match = |c: &Condition| match c {
|
||||
// Tuple variant: match Exhaustion(level) against the
|
||||
// snapshot's `exhaustion` value. A snapshot whose level
|
||||
// differs from the timer's level does NOT match (no
|
||||
// cross-level override), and a legacy snapshot entry
|
||||
// with no `exhaustion` field matches nothing — both
|
||||
// keep the whole override from applying, exactly as the
|
||||
// missing-string case does for unit variants.
|
||||
Condition::Exhaustion(level) => snap_condition
|
||||
.get("exhaustion")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|lvl| lvl as u8)
|
||||
== Some(*level),
|
||||
// Unit variants: externally-tagged serialise as a bare
|
||||
// quoted string ("frightened"); compare names after
|
||||
// trimming the quotes.
|
||||
_ => {
|
||||
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) {
|
||||
@@ -3127,11 +3145,6 @@ impl GameSession {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_entity_conservation(&self, previous_count: usize, ingress_count: usize, egress_count: usize) -> bool {
|
||||
let expected = previous_count + ingress_count - egress_count;
|
||||
self.entities.len() == expected
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- combat flow
|
||||
|
||||
/// Starts combat: rolls initiative (d20 + DEX modifier per entity), orders
|
||||
@@ -3340,6 +3353,54 @@ fn parse_contest_condition(value: Option<&serde_json::Value>) -> Option<Conditio
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the `condition` field of a CONDITION_APPLIED ledger payload back
|
||||
/// into a [`Condition`], recognising both serde serialisation shapes:
|
||||
/// - unit variants serialise externally-tagged as a bare snake-case string
|
||||
/// ("frightened") — matched against each representative's serialised name;
|
||||
/// - the sole tuple variant, [`Condition::Exhaustion(u8)`], serialises as an
|
||||
/// object {"exhaustion": <level>} — matched on its numeric payload so the
|
||||
/// exact level survives the JSON round-trip.
|
||||
///
|
||||
/// Grappled is deliberately absent from the unit-variant probes: it is the
|
||||
/// contest pipeline's condition, replayed from its own events rather than from
|
||||
/// CONDITION_APPLIED, matching the pre-existing rebuild behaviour. Iteration 30
|
||||
/// (F4): this is the first path that arms Exhaustion clocks — before it, the
|
||||
/// object shape made `.as_str()` return None and an Exhaustion timer was
|
||||
/// invisible to the timed-condition replay, so a rewind neither re-armed it
|
||||
/// from the journal nor correctly decremented it.
|
||||
fn parse_timed_condition(value: Option<&serde_json::Value>) -> Option<Condition> {
|
||||
let value = value?;
|
||||
if let Some(name) = value.as_str() {
|
||||
[
|
||||
Condition::Blinded,
|
||||
Condition::Charmed,
|
||||
Condition::Deafened,
|
||||
Condition::Frightened,
|
||||
Condition::Incapacitated,
|
||||
Condition::Invisible,
|
||||
Condition::Paralyzed,
|
||||
Condition::Petrified,
|
||||
Condition::Poisoned,
|
||||
Condition::Prone,
|
||||
Condition::Restrained,
|
||||
Condition::Stunned,
|
||||
Condition::Unconscious,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|rep| {
|
||||
serde_json::to_string(rep).unwrap_or_default().trim_matches('"') == name
|
||||
})
|
||||
} else {
|
||||
// Tuple variant: Exhaustion(level). Legacy payloads without the level
|
||||
// object (or a non-numeric level) resolve to no condition, and the
|
||||
// event is skipped exactly as the string path skips unknown names.
|
||||
value
|
||||
.get("exhaustion")
|
||||
.and_then(|lvl| lvl.as_u64())
|
||||
.map(|lvl| Condition::Exhaustion(lvl as u8))
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the serde name of a condition variant (as serialized into a ledger
|
||||
/// payload) names a condition with the given discriminant. Exhaustion's payload
|
||||
/// form is level-bearing, so it is matched by its bare-name prefix rather than
|
||||
@@ -4488,7 +4549,93 @@ mod tests {
|
||||
.expect("clock rebuilt");
|
||||
assert_eq!(
|
||||
clock.remaining_rounds, 3,
|
||||
"rewind must apply TURN_ADVANCED's clock snapshot over the CONDITION_APPLIED duration"
|
||||
"rewind must apply TURN_ADVANCED's snapshot over the CONDITION_APPLIED duration"
|
||||
);
|
||||
}
|
||||
|
||||
// F4 (iteration 30): Condition::Exhaustion(u8) is the only tuple variant.
|
||||
// Externally-tagged serde serialises it as {"exhaustion": <level>} (an
|
||||
// object), not as a bare "exhaustion" string like the unit variants. The
|
||||
// F6 TURN_ADVANCED snapshot override restores the partway-decremented
|
||||
// `remaining_rounds` by comparing the snapshot's `condition` field; for a
|
||||
// unit variant that field is a plain string and matches by name, but for
|
||||
// Exhaustion it is an object whose `.as_str()` is None, so the recorded
|
||||
// remaining_rounds was silently dropped and an exhausted creature rewound
|
||||
// to its full pre-decrement clock.
|
||||
#[test]
|
||||
fn test_rewind_restores_partial_exhaustion_clock_from_last_turn_advanced_snapshot() {
|
||||
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::Exhaustion(2), 4, None, None)
|
||||
.unwrap();
|
||||
let mut dice = DiceEngine::with_seed(1);
|
||||
session.advance_round(&mut dice); // exhaustion timer 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: the exhaustion 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::Exhaustion(2))
|
||||
.expect("exhaustion clock rebuilt");
|
||||
assert_eq!(
|
||||
clock.remaining_rounds, 3,
|
||||
"rewind must restore the decremented Exhaustion clock (never the full duration)"
|
||||
);
|
||||
}
|
||||
|
||||
// F4 (iteration 30) companion, journal-authority half: before the
|
||||
// timed-condition rebuild recognised the Exhaustion tuple variant, an
|
||||
// Exhaustion clock was invisible to the journal — a rewind simply leaked
|
||||
// whatever value the live in-memory timer happened to hold. Rewinding past
|
||||
// the advance_round (so no TURN_ADVANCED snapshot survives to apply an
|
||||
// override) therefore returned the leaked 3 instead of the journal's full
|
||||
// 4. The rebuild must re-arm the clock from the CONDITION_APPLIED payload
|
||||
// so the rewind is journal-authoritative.
|
||||
#[test]
|
||||
fn test_rewind_derives_exhaustion_clock_from_journal_at_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::Exhaustion(2), 4, None, None)
|
||||
.unwrap();
|
||||
let apply_seq = session.ledger.current_sequence;
|
||||
let mut dice = DiceEngine::with_seed(1);
|
||||
session.advance_round(&mut dice); // 4 -> 3, TURN_ADVANCED journaled
|
||||
// Rewind past the advance entirely: no TURN_ADVANCED snapshot
|
||||
// survives, so the clock must be rebuilt at its full journaled
|
||||
// duration (not left at the leaked live value).
|
||||
session.safety_rewind(apply_seq);
|
||||
let clock = session
|
||||
.entities
|
||||
.get(&victim)
|
||||
.unwrap()
|
||||
.condition_timers
|
||||
.iter()
|
||||
.find(|t| t.condition == Condition::Exhaustion(2))
|
||||
.expect("exhaustion clock rebuilt from journal");
|
||||
assert_eq!(
|
||||
clock.remaining_rounds, 4,
|
||||
"rewind past the decrement must restore the full journaled Exhaustion duration"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user