Merge pull request 'D&D 5e death-save state machine, undo/redo fixes, dev bulk delete' (#4) from single-source into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
# Death Saves 5e - Implementation Plan
|
||||
|
||||
TDD required. Tests first, implementation after.
|
||||
|
||||
## Gap Analysis
|
||||
|
||||
**Current:**
|
||||
- ✅ Separate success/fail counters (deathSaves, deathFails)
|
||||
- ✅ 3 success → stable, 3 fail → dead
|
||||
- ✅ UI shows ✓/✕ buttons, status badges
|
||||
|
||||
**Missing (from DEATH_SAVES.md):**
|
||||
- ❌ Nat 20: 1 HP + conscious, reset counters
|
||||
- ❌ Nat 1: 2 failures
|
||||
- ❌ Damage at 0 HP → add failures (normal=1, crit=2)
|
||||
- ❌ Massive damage: damage >= currentHp + maxHp → instant dead
|
||||
- ❌ Stabilize manual action
|
||||
- ❌ Healing resets counters
|
||||
- ❌ Damage breaks stable → back to dying
|
||||
|
||||
**Test Gap:**
|
||||
- turn.deathsave.test.js: basic counters only
|
||||
- turn.combat.test.js: single deathSave call, no coverage
|
||||
- Combat.scenario.test.js: basic fail/death flow
|
||||
- No nat20, nat1, damage-at-0, massive damage, stabilize, healing integration
|
||||
|
||||
## Phase 1: Extend Tests (TDD)
|
||||
|
||||
### File: `shared/tests/turn.deathsave.nat20.test.js`
|
||||
|
||||
Test cases:
|
||||
- Nat20 → hp=1, deathSaves=0, deathFails=0, isDying=false, isStable=false, status="conscious"
|
||||
- Nat20 overrides existing saves/fails
|
||||
- Nat20 log entry correct
|
||||
|
||||
### File: `shared/tests/turn.deathsave.nat1.test.js`
|
||||
|
||||
Test cases:
|
||||
- Nat1 → deathFails += 2
|
||||
- Nat1 triggers instant death (3 fails total)
|
||||
- Nat1 log entry correct
|
||||
|
||||
### File: `shared/tests/turn.deathsave.damage.test.js`
|
||||
|
||||
Test cases:
|
||||
- Damage at 0 HP → deathFails += 1
|
||||
- Crit damage at 0 HP → deathFails += 2
|
||||
- Mixed: save success + damage → independent tracking works
|
||||
- Damage log includes "while dying"
|
||||
|
||||
### File: `shared/tests/turn.deathsave.massive.test.js`
|
||||
|
||||
Test cases:
|
||||
- Damage >= currentHp + maxHp → instant dead
|
||||
- Bypasses death saves entirely
|
||||
- Log entry shows "massive damage"
|
||||
|
||||
### File: `shared/tests/turn.deathsave.stabilize.test.js`
|
||||
|
||||
Test cases:
|
||||
- Stabilize → hp=0, isStable=true, isDying=false, counters=0
|
||||
- Stabilize from dying state
|
||||
- Stabilize log entry correct
|
||||
|
||||
### File: `shared/tests/turn.deathsave.heal.test.js`
|
||||
|
||||
Test cases:
|
||||
- Healing at 0 HP → reset counters, isDying=false, isStable=false
|
||||
- Healing at stable → same reset
|
||||
- Healing log includes "cleared death saves"
|
||||
|
||||
### File: `shared/tests/turn.deathsave.stable.test.js`
|
||||
|
||||
Test cases:
|
||||
- Damage to stable → isStable=false, isDying=true, counters reset
|
||||
- Stable takes damage → fresh death save loop
|
||||
|
||||
### File: `shared/tests/turn.deathsave.undo.test.js`
|
||||
|
||||
Test cases:
|
||||
- Undo deathSave restores previous state
|
||||
- Undo nat20 restores counters
|
||||
- Undo damage at 0 HP restores fail count
|
||||
- Undo stabilize restores dying state
|
||||
|
||||
## Phase 2: Combat Test Integration
|
||||
|
||||
### Update `shared/tests/turn.combat.test.js`
|
||||
|
||||
Add phases:
|
||||
- Nat20 phase: roll nat20, verify 1 HP + conscious
|
||||
- Nat1 phase: roll nat1, verify 2 failures
|
||||
- Damage-at-zero phase: normal damage + crit damage at 0 HP
|
||||
- Massive damage phase: instant death bypass
|
||||
- Stabilize phase: stabilize dying character
|
||||
- Healing-reset phase: heal at 0 HP, verify counters reset
|
||||
- Assert death save state at each phase
|
||||
|
||||
### Update `src/tests/Combat.scenario.test.js`
|
||||
|
||||
Add action functions:
|
||||
- deathSaveActionNat20(name)
|
||||
- deathSaveActionNat1(name)
|
||||
- stabilizeAction(name)
|
||||
- applyDamageAtZero(name, amount, isCrit)
|
||||
- applyMassiveDamage(name)
|
||||
- healingResetAction(name, amount)
|
||||
|
||||
Add scenario phases:
|
||||
- Round 10-20: nat20/nat1 scenarios
|
||||
- Round 30-40: damage-at-zero scenarios (normal + crit)
|
||||
- Round 50-60: stabilize scenarios
|
||||
- Round 70-80: massive damage scenarios
|
||||
- Round 90-100: healing-reset scenarios
|
||||
- Verify log entries include all death save types
|
||||
|
||||
### Update `scripts/combat.js`
|
||||
|
||||
Verify death save events:
|
||||
- Check death_save events in log
|
||||
- Verify nat20, nat1, damage_at_zero, massive_damage log types
|
||||
- Verify stabilize events
|
||||
- Verify healing_reset events
|
||||
|
||||
## Phase 3: Extend `deathSave` Function
|
||||
|
||||
Add roll result parameter:
|
||||
```ts
|
||||
async function deathSave(encounter, participantId, type, n, rollResult, ctx) {
|
||||
// rollResult: number (1-20) or null for manual input
|
||||
}
|
||||
```
|
||||
|
||||
Handle nat20:
|
||||
```ts
|
||||
if (rollResult === 20) {
|
||||
updates.hp = 1;
|
||||
updates.deathSaves = 0;
|
||||
updates.deathFails = 0;
|
||||
updates.isDying = false;
|
||||
updates.isStable = false;
|
||||
status = 'conscious';
|
||||
}
|
||||
```
|
||||
|
||||
Handle nat1:
|
||||
```ts
|
||||
if (rollResult === 1) {
|
||||
const currentFails = participant.deathFails || 0;
|
||||
updates.deathFails = Math.min(3, currentFails + 2);
|
||||
// Check for death...
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 4: Extend `applyHpChange` Function
|
||||
|
||||
Add damage-at-zero logic:
|
||||
```ts
|
||||
if (changeType === 'damage' && participant.currentHp === 0) {
|
||||
const failuresToAdd = isCritical ? 2 : 1;
|
||||
const currentFails = participant.deathFails || 0;
|
||||
updates.deathFails = Math.min(3, currentFails + failuresToAdd);
|
||||
updates.isStable = false;
|
||||
// Check for death...
|
||||
}
|
||||
```
|
||||
|
||||
Add massive damage rule:
|
||||
```ts
|
||||
if (changeType === 'damage' && damage >= participant.currentHp + participant.maxHp) {
|
||||
updates.hp = 0;
|
||||
updates.isDying = true;
|
||||
updates.deathSaves = 0;
|
||||
updates.deathFails = 3;
|
||||
updates.isStable = false;
|
||||
}
|
||||
```
|
||||
|
||||
Add healing reset logic:
|
||||
```ts
|
||||
if (changeType === 'heal' && (participant.currentHp === 0 || participant.isStable || participant.isDying)) {
|
||||
updates.deathSaves = 0;
|
||||
updates.deathFails = 0;
|
||||
updates.isDying = false;
|
||||
updates.isStable = false;
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 5: New `stabilizeParticipant` Function
|
||||
|
||||
```ts
|
||||
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
// hp=0, isStable=true, isDying=false, counters=0
|
||||
// Log: "Stabilized"
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 6: UI Extensions
|
||||
|
||||
### Phase 6a: Roll Result Modal
|
||||
|
||||
Add roll result input:
|
||||
- Modal when clicking death save button
|
||||
- Input: d20 result (1-20) or "manual"
|
||||
- Auto-detect nat20/nat1
|
||||
|
||||
### Phase 6b: Nat1/Nat20/Stabilize Buttons
|
||||
|
||||
Death save row layout:
|
||||
```
|
||||
[Saves: □ □ □] [Fails: □ □ □]
|
||||
[Nat1] [Nat20] [Stabilize]
|
||||
```
|
||||
|
||||
**Nat1 button:**
|
||||
- Red icon (AlertTriangle)
|
||||
- Click → deathSave(participantId, 'fail', null, 1, ctx)
|
||||
- Adds 2 failures immediately
|
||||
- Log: "Nat 1! [name] takes 2 death save failures"
|
||||
- Disable if deathFails >= 2 (would cause death anyway)
|
||||
|
||||
**Nat20 button:**
|
||||
- Gold icon (Zap)
|
||||
- Click → deathSave(participantId, 'success', null, 20, ctx)
|
||||
- Sets hp=1, resets counters, conscious
|
||||
- Log: "Nat 20! [name] restored to 1 HP, conscious"
|
||||
|
||||
**Stabilize button:**
|
||||
- Emerald icon (HeartPulse)
|
||||
- Only show when dying (hp=0, !isStable, !dead)
|
||||
- Click → stabilizeParticipant(participantId, ctx)
|
||||
- Sets isStable=true, counters=0
|
||||
- Log: "[name] stabilized at 0 HP"
|
||||
- Hide if already isStable
|
||||
|
||||
**Conditional visibility:**
|
||||
- All buttons: only when hp === 0 && !dead
|
||||
- Stabilize: hide if already isStable
|
||||
- Nat1: disable if deathFails >= 2
|
||||
|
||||
### Phase 6c: Crit Checkbox for Damage
|
||||
|
||||
Add crit checkbox:
|
||||
- For damage at 0 HP
|
||||
- Pass crit to applyHpChange
|
||||
- UI: checkbox "Critical hit?"
|
||||
|
||||
### Phase 6d: Enhanced Log Messages
|
||||
|
||||
- Nat20: "Nat 20! [name] restored to 1 HP, conscious"
|
||||
- Nat1: "Nat 1! [name] takes 2 death save failures"
|
||||
- Damage at 0: "[name] takes [n] damage while dying"
|
||||
- Massive damage: "Massive damage! [name] instantly killed"
|
||||
- Stabilize: "[name] stabilized at 0 HP"
|
||||
- Healing reset: "[name] healed to [n] HP, death saves cleared"
|
||||
|
||||
## Phase 7: Integration Tests
|
||||
|
||||
File: `shared/tests/turn.deathsave.flow.test.js`
|
||||
|
||||
Test full scenarios:
|
||||
- Dying → nat20 → conscious → take damage → dying
|
||||
- Dying → nat1 → 2 fails → another fail → dead
|
||||
- Dying → stabilize → damage → dying again
|
||||
- Massive damage bypasses all
|
||||
- Healing resets mid-death-save loop
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Phase 1: Write all test files (fail expected)
|
||||
2. Phase 2: Update combat tests (fail expected)
|
||||
3. Phase 3: Extend deathSave, run tests
|
||||
4. Phase 4: Extend applyHpChange, run tests
|
||||
5. Phase 5: Add stabilizeParticipant, run tests
|
||||
6. Phase 6a: Roll result modal
|
||||
7. Phase 6b: Nat1/Nat20/Stabilize buttons
|
||||
8. Phase 6c: Crit checkbox
|
||||
9. Phase 6d: Enhanced log messages
|
||||
10. Phase 7: Integration tests
|
||||
11. Manual testing: full scenarios in dev server
|
||||
|
||||
## Risk Notes
|
||||
|
||||
- Roll result input: optional field (DM may not want to input every roll)
|
||||
- Crit detection: manual checkbox (no way to auto-detect)
|
||||
- Massive damage: optional rule (some tables ignore)
|
||||
- Backward compat: existing API must still work with null rollResult
|
||||
- Nat1/Nat20/Stabilize buttons: DM-optional, not required to use
|
||||
@@ -4,6 +4,75 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
|
||||
## Open
|
||||
|
||||
|
||||
|
||||
### dm list - keep active particpant in view (scroll)
|
||||
not sure good way to do this
|
||||
|
||||
|
||||
|
||||
### FEAT: player display fade transitions for inactive state
|
||||
- Inactive is DM-triggered via Mark Inactive.
|
||||
- Player display should fade inactive participant out, then remove from display list.
|
||||
- Reactivating should fade participant in.
|
||||
- DM display keeps inactive participant visible.
|
||||
- Dead state does not imply inactive/disabled.
|
||||
- Dying/stable must not fade out or leave layout holes.
|
||||
- Dead can keep skull/Dead label; create some good visual cues, no removal - but a cool transition to death would be nice.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### TEST GAP: current branch changes need focused coverage
|
||||
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
|
||||
- Server SQL query test: `where + orderBy + limit` should return latest logs for one encounter only.
|
||||
- Undo/redo stack order: undo A3 then A2, redo must replay A2 first, then A3.
|
||||
- Combat controls should not subscribe to logs while mounted; undo/redo should query logs only on click.
|
||||
- Unified CLI smoke: `node scripts/combat.js verify <fixture.json>` returns CLEAN on known-good log.
|
||||
- Unified CLI replay smoke: `node scripts/combat.js replay ... --out tmp/x.json` writes JSON array and auto-verifies.
|
||||
- Ctrl-C replay behavior: SIGINT during replay should end encounter, clear active display, write partial log, run verify.
|
||||
- SQLite schema/index test: `idx_docs_parent_ts` and `idx_docs_parent_encounter_ts` exist for server DB.
|
||||
|
||||
### confirm warnings treated as error = fail in all tests, build pipeline, linters, everything. again.
|
||||
|
||||
### BUG: addParticipants (batch add) does not slot by initiative
|
||||
- shared/turn.js addParticipants appends `[...existing, ...new]`, no slotIndexForInit.
|
||||
- Violates INIT doc: "Add = insert into slot by initiative."
|
||||
- Single addParticipant slots correct. Batch (add-all-chars) appends.
|
||||
- Pre-start batch add = wrong order. Post-start worse.
|
||||
|
||||
### BUG: nextTurn throws on solo combatant
|
||||
- nextActiveAfter loop `for step=1; step<n` skips when n=1 → {nextId:null}.
|
||||
- nextTurn throws "Could not determine next participant."
|
||||
- Solo active combat cannot pass turn.
|
||||
|
||||
### BUG: reorderParticipants cross-pointer drag = silent no-op
|
||||
- Cross-pointer drag returns encounter unchanged, no log, no toast.
|
||||
- DM drags across current turn → nothing happens, no feedback.
|
||||
|
||||
### BUG: addParticipant undo missing currentTurnParticipantId when started
|
||||
- undo saves participants + conditional turnOrderIds, no currentTurnParticipantId.
|
||||
- Pointer can misalign on undo if added near pointer region.
|
||||
|
||||
### BUG: computeTurnOrderAfterRemoval isActive uses find() not boolean
|
||||
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
||||
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
|
||||
|
||||
### BUG: select campaign during active combat = screen flicker, no action
|
||||
- In active encounter, click different campaign → flicker, no nav, no end.
|
||||
- Either block (toast: end encounter first) or auto-end current + switch.
|
||||
- Decide UX before fix.
|
||||
|
||||
|
||||
### FEAT: generic/non-5e rules mode
|
||||
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
|
||||
- Generic mode turns off death saves/state machine.
|
||||
- Generic mode allows negative HP.
|
||||
- 5e mode rejects negative damage/heal and clamps HP at 0.
|
||||
|
||||
|
||||
|
||||
### FEAT: clarify "Is NPC" in add-participant
|
||||
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
||||
display-only? skip in turn order?). Clarify intent before UX changes.
|
||||
@@ -28,6 +97,9 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
- Migration: keep old log entries readable; new format for new writes.
|
||||
|
||||
|
||||
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
|
||||
|
||||
|
||||
## FEAT - parallel campaigns
|
||||
|
||||
## FEAT - multi user
|
||||
@@ -76,12 +148,13 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
|
||||
string ids survive round-trip.
|
||||
|
||||
### Death saves: D&D 5e model (DONE)
|
||||
- Separate success/fail tracking. deathSave(enc, id, type, n),
|
||||
type='success'|'fail'. Fields: deathSaves, deathFails, isStable, isDying.
|
||||
3 success=stable, 3 fail=dead. Returns {patch, log, status, isDying}.
|
||||
Old single-counter broken (successes missing). Old data lost (feature
|
||||
never worked in prod). UI: green ✓ + red ✕ rows, Stabilized/Dying badges.
|
||||
### Death saves: D&D 5e status model (DONE)
|
||||
- `status` is source of truth: conscious, dying, stable, dead.
|
||||
- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP.
|
||||
- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize.
|
||||
- Revive: dead → 0 HP, stable/unconscious, active.
|
||||
- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive.
|
||||
- Player display hides inactive participants; DM display keeps them visible.
|
||||
|
||||
### FEAT-3: initiative first-class entry (DONE)
|
||||
- Initiative field at add-char, add-monster, edit participant.
|
||||
@@ -155,9 +228,9 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
### BUG-18: stale comments reference deleted memory adapter
|
||||
- FIXED.
|
||||
|
||||
### FEAT-1: Dead participants stay in turn order
|
||||
- DONE --- applyHpChange no longer flips isActive on death. Dead stay in
|
||||
rotation, nextTurn visits them, PCs get death-save turn.
|
||||
### FEAT-1: Dead characters/NPCs stay in turn order
|
||||
- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove.
|
||||
- Non-NPC monsters still become inactive automatically at death.
|
||||
|
||||
### combat.scenario 100 rounds not turns
|
||||
- DONE --- loops by actual round-wrap count.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Death Saves (D&D 5e)
|
||||
|
||||
Concept and rule structure for the death-save state machine. Reference for how
|
||||
combat tracking should behave at 0 HP. This is the 5e death-save loop, not a
|
||||
code spec.
|
||||
|
||||
## Scope: tracker tracks, DM decides
|
||||
|
||||
This tracker is not a virtual tabletop. It does not roll dice or interpret the
|
||||
table. The DM rolls at the table, decides what happened, and feeds the result
|
||||
into the tracker through the UI. The tracker records the result and applies the
|
||||
automatic pieces.
|
||||
|
||||
- DM does: rolls the d20, determines crits vs normal hits, declares damage
|
||||
amounts, decides when to stabilize or heal.
|
||||
- Tracker does: increments success/failure counters on DM input, resolves the
|
||||
terminal states (3 successes → stable, 3 failures → dead), resets counters on
|
||||
heal or stabilize, records HP changes.
|
||||
|
||||
The transitions below describe the rules the tracker must honor once the DM
|
||||
hands it a result. The DM is the source of truth for what happened; the tracker
|
||||
is the source of truth for what state that produces.
|
||||
|
||||
## State
|
||||
|
||||
A character or NPC at 0 HP enters a death-save loop. Track per death-save participant:
|
||||
|
||||
```ts
|
||||
deathSaveSuccesses: 0 | 1 | 2 | 3
|
||||
deathSaveFailures: 0 | 1 | 2 | 3
|
||||
status: "conscious" | "dying" | "stable" | "dead"
|
||||
hp: number
|
||||
```
|
||||
|
||||
## Transitions
|
||||
|
||||
**Drop to 0 HP** (damage, character/NPC):
|
||||
- If damage >= currentHp + maxHp → outright dead (massive damage). Equivalently: after reducing currentHp to 0, overflow damage >= maxHp.
|
||||
- Otherwise → `status = "dying"`, counters reset to 0.
|
||||
|
||||
**Drop to 0 HP** (monster):
|
||||
- Non-NPC monsters do not use death saves.
|
||||
- `status = "dead"`, counters reset, `isActive = false`.
|
||||
|
||||
**Death save** (d20 roll on a dying character/NPC turn):
|
||||
|
||||
```ts
|
||||
if roll === 1: failures += 2 // natural 1 = two failures
|
||||
else if roll === 20: hp = 1; successes = 0; failures = 0; status = "conscious" // nat 20 = 1 HP, conscious
|
||||
else if roll >= 10: successes += 1
|
||||
else: failures += 1
|
||||
```
|
||||
|
||||
Then resolve immediately on the click/action that reaches 3:
|
||||
|
||||
```ts
|
||||
if failures >= 3: status = "dead"; successes = 0; failures = 0
|
||||
if successes >= 3: status = "stable"; successes = 0; failures = 0
|
||||
```
|
||||
|
||||
The UI must transition immediately. A third failure shows Dead state, not three
|
||||
active failure boxes. A third success shows Stable state, not three active
|
||||
success boxes.
|
||||
|
||||
**Damage while dying** (at 0 HP):
|
||||
|
||||
```ts
|
||||
failures += isCriticalHit ? 2 : 1
|
||||
if failures >= 3: status = "dead"
|
||||
```
|
||||
|
||||
Attacks from within 5 ft against an unconscious creature are auto-crits, so
|
||||
melee vs a dying character/NPC adds 2 failures.
|
||||
|
||||
**Healing while dying or stable**:
|
||||
|
||||
Any +HP received while `dying` or `stable` makes the character/NPC conscious.
|
||||
This usually comes from magic, or from the stable-state time-delayed recovery
|
||||
(1 HP after 1d4 hours, if the DM chooses to track it here).
|
||||
|
||||
```ts
|
||||
hp = max(1, hp + amount)
|
||||
successes = 0
|
||||
failures = 0
|
||||
status = "conscious"
|
||||
```
|
||||
|
||||
Any HP regained from `dying` or `stable` clears all counters and ends the
|
||||
death-save loop. This does **not** apply to `dead`; dead requires revive flow,
|
||||
revival magic, or DM state override, not normal healing.
|
||||
|
||||
**Stabilize** (manual action, not a save):
|
||||
|
||||
```ts
|
||||
hp = 0
|
||||
successes = 0
|
||||
failures = 0
|
||||
status = "stable"
|
||||
```
|
||||
|
||||
## Stable state
|
||||
|
||||
- Stays at 0 HP, unconscious, no longer makes death saves.
|
||||
- 3 successes = **stable (unconscious)**, NOT conscious. NOT 1 HP.
|
||||
1 HP only comes from a natural 20 or healing.
|
||||
- Taking any damage ends stable → back to `dying` (counters reset, then damage
|
||||
applies its failure per "damage while dying").
|
||||
|
||||
## Tool-specific initiative behavior
|
||||
|
||||
Death is a combat state, not an automatic removal action.
|
||||
|
||||
When a character or NPC becomes `dead`:
|
||||
- Do **not** remove them from initiative.
|
||||
- Do **not** automatically deactivate them.
|
||||
- Do **not** remove them from the encounter participants list.
|
||||
- Leave them in turn order. Other table/tool events may still matter on that
|
||||
initiative count.
|
||||
- DM may manually mark them inactive or remove them.
|
||||
|
||||
When a non-NPC monster becomes `dead`:
|
||||
- Set `isActive = false` automatically.
|
||||
- Keep it in the encounter participants list until DM removes it.
|
||||
- Player display hides it because inactive participants are hidden.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `status === "dying"` ⟺ hp === 0 AND no terminal flag.
|
||||
- `status === "stable"` ⟹ hp === 0, unconscious, counters 0.
|
||||
- `status === "dead"` ⟹ hp === 0, no further saves, counters 0.
|
||||
- `status === "dead"` ⟹ participant remains in initiative and participants until DM removes them.
|
||||
- `status === "dead"` does not itself mean inactive; monster death sets inactive as a separate rule.
|
||||
- Normal +HP applies to `dying` or `stable`, not `dead`; dead requires revive, revival, or DM override.
|
||||
- Successes and failures tracked **independently**. 2 fail + 3 success = stable.
|
||||
Counters do not need to be consecutive.
|
||||
- A roll of 10+ is a success. 9 or lower is a failure.
|
||||
|
||||
## Sources
|
||||
|
||||
- [PHB 5e: Death Saving Throws](https://roll20.net/compendium/dnd5e/Rules%3ADamage%20and%20Healing)
|
||||
- [Reddit: What happens on 3 successful death saves?](https://www.reddit.com/r/DnD/comments/io5js7/what_happens_if_you_succeed_in_3_death_saving/)
|
||||
- [Roll20: Conditions (Unconscious)](https://roll20.net/compendium/dnd5e/Conditions)
|
||||
+1
-1
@@ -174,7 +174,7 @@ Runs pure turn.js combat, audits 9 invariant classes per round:
|
||||
|
||||
1. rotation integrity (skip/dupe)
|
||||
2. HP bounds (0 ≤ hp ≤ max, no NaN)
|
||||
3. isActive consistency (dead = inactive)
|
||||
3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
|
||||
4. turnOrder no dup ids
|
||||
5. turnOrder ids all active
|
||||
6. currentTurn valid + active
|
||||
|
||||
+28
-18
@@ -53,16 +53,16 @@ Object in `encounter.participants[]`:
|
||||
|---|---|---|
|
||||
| `id` | string | `generateId()` |
|
||||
| `name` | string | |
|
||||
| `type` | `'character'` \| `'monster'` | character = PC (death saves), monster = hostile/NPC |
|
||||
| `type` | `'character'` \| `'npc'` \| `'monster'` | character/NPC = death saves, monster = dead at 0 HP |
|
||||
| `originalCharacterId` | string\|null | links back to campaign character if type=character |
|
||||
| `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. |
|
||||
| `maxHp` | int | |
|
||||
| `currentHp` | int | 0 = dead/dying |
|
||||
| `isNpc` | bool | monster flagged NPC (display color, no death saves) |
|
||||
| `currentHp` | int | 0 = dead/dying/stable by `status` |
|
||||
| `conditions` | array | condition ids from `CONDITIONS` list |
|
||||
| `isActive` | bool | in turn rotation? false = skipped by nextTurn |
|
||||
| `deathSaves` | int | PC only, 0-3 fails |
|
||||
| `isDying` | bool | death animation flag (player display) |
|
||||
| `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth |
|
||||
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
|
||||
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
|
||||
|
||||
## Build flow (UI)
|
||||
|
||||
@@ -99,7 +99,7 @@ ParticipantManager section. Two paths:
|
||||
- **Monster Name** (`placeholder: "e.g., Dire Wolf"`)
|
||||
- **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2)
|
||||
- **Max HP** (`DEFAULT_MAX_HP` = 10)
|
||||
- **Is NPC?** checkbox (flag, changes display color)
|
||||
- **Is NPC?** checkbox (sets `type: 'npc'`, keeps monster/DM-control color, enables death saves)
|
||||
- Click **Add to Encounter**
|
||||
- Initiative auto-rolled: `rollD20() + mod`
|
||||
|
||||
@@ -113,13 +113,13 @@ ParticipantManager section. Two paths:
|
||||
Participant object added:
|
||||
```js
|
||||
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
|
||||
isNpc, conditions:[], isActive:true, deathSaves:0, isDying:false }
|
||||
conditions:[], isActive:true, status:'conscious', deathSaveSuccesses:0, deathSaveFailures:0 }
|
||||
```
|
||||
|
||||
### 6. Reorder before start (tie-break)
|
||||
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`.
|
||||
|
||||
Post-start drag: see BUG-13/14 in `TODO.md` (cross-init + pointer semantics untested).
|
||||
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
|
||||
|
||||
## Combat flow (UI)
|
||||
|
||||
@@ -135,8 +135,9 @@ InitiativeControls panel (sticky, right side).
|
||||
### Next Turn
|
||||
- **Next Turn** button (disabled if paused)
|
||||
- Advances to next active participant in `turnOrderIds`
|
||||
- Wraps at end → `round += 1`, re-sorts active by initiative at round start
|
||||
- Dead (`isActive:false`) skipped, stay in rotation
|
||||
- Wraps at end → `round += 1`
|
||||
- No re-sort after start; 1-list order remains source of truth
|
||||
- Inactive (`isActive:false`) participants are skipped, but stay in slot
|
||||
|
||||
### Pause / Resume
|
||||
- **Pause Combat** → `isPaused=true`, Next Turn disabled
|
||||
@@ -147,11 +148,19 @@ InitiativeControls panel (sticky, right side).
|
||||
Per-participant input + buttons:
|
||||
- Number input
|
||||
- **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)`
|
||||
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)`
|
||||
- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking
|
||||
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` for living/dying/stable participants; normal heal does not affect dead participants
|
||||
- Character/NPC at 0 HP → `status:'dying'`, death-save controls, unconscious condition
|
||||
- Monster at 0 HP → `status:'dead'`, `isActive:false`
|
||||
- Massive damage (`damage >= currentHp + maxHp`) → dead
|
||||
|
||||
### Death saves (PC only, at 0 HP)
|
||||
3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal.
|
||||
### Death saves (character/NPC only, at 0 HP)
|
||||
Action buttons: Success, Fail, Nat1, Nat20, Stabilize.
|
||||
- Success: +1 success; 3 successes → stable/unconscious at 0 HP
|
||||
- Fail: +1 failure; 3 failures → dead
|
||||
- Nat1: +2 failures
|
||||
- Nat20: 1 HP, conscious, counters reset
|
||||
- Stabilize: stable/unconscious at 0 HP, counters reset
|
||||
- Revive: dead → 0 HP, stable/unconscious, active
|
||||
|
||||
### Conditions
|
||||
- Click participant → expand conditions picker (all 22 from `CONDITIONS`)
|
||||
@@ -170,7 +179,7 @@ What it shows:
|
||||
- Round + current turn participant
|
||||
- All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix)
|
||||
- HP bars, conditions, death saves
|
||||
- Inactive monsters hidden (pre-staged reserves)
|
||||
- Inactive participants hidden (including auto-inactive dead monsters and DM-hidden reserves)
|
||||
|
||||
Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets).
|
||||
|
||||
@@ -181,11 +190,11 @@ Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single
|
||||
- **Display** = `participants[]` order (AdminView + DisplayView, no re-sort)
|
||||
- **Turn rotation** = `turnOrderIds` (mirrors participants[])
|
||||
- **Drag** = source of truth, overrides initiative
|
||||
- **Add mid-combat** = append to participants[] + sync (BUG-14: init-insert broken post-drag)
|
||||
- **Add mid-combat** = slot by initiative into participants[] + sync
|
||||
- **Toggle active** = flip `isActive` only, stay in slot
|
||||
- **Remove** = drop from participants[] + sync, advance current if needed
|
||||
|
||||
No re-sort after `startEncounter` except round-wrap (re-sorts active by init at top of round).
|
||||
No re-sort after `startEncounter`.
|
||||
|
||||
## Storage paths quick reference
|
||||
|
||||
@@ -202,7 +211,8 @@ logs/{logId} action log entry
|
||||
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
|
||||
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
|
||||
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added).
|
||||
- Dead monsters stay in rotation, skipped. Remove via trash icon to clean list.
|
||||
- Dead monsters become inactive automatically and are skipped. Remove via trash icon to clean list.
|
||||
- Dead characters/NPCs stay active until DM marks inactive, removes, or revives them.
|
||||
- Player display auto-follows Start Combat. Manual control via Open Player Window.
|
||||
|
||||
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
|
||||
|
||||
@@ -28,7 +28,7 @@ One list: `participants[]`.
|
||||
| **Start encounter** | Freeze current list. No re-sort. |
|
||||
| **Round wrap** | No rebuild. Continue rotation through existing list. |
|
||||
| **Damage/heal/death/save** | No order change. |
|
||||
| **Toggle active** | No position change. Skip in rotation only. |
|
||||
| **Toggle active** | No position change. Does **not** advance current turn. Skip on next explicit turn advance only. |
|
||||
|
||||
## Slotting Rules
|
||||
|
||||
@@ -44,6 +44,20 @@ One list: `participants[]`.
|
||||
- Drag persists in `participants[]`. Survives all subsequent operations.
|
||||
- **Re-slotting on add/edit must preserve drag-established tie order.**
|
||||
|
||||
## Toggle Active Semantics
|
||||
|
||||
Toggle active is a roster/status edit, not a turn action.
|
||||
|
||||
- Deactivating the current participant leaves `currentTurnParticipantId` unchanged.
|
||||
- Deactivating the current participant does **not** increment `round`.
|
||||
- DM must click Next Turn to pass turn after deactivating current.
|
||||
- Next Turn skips inactive participants during rotation.
|
||||
- Reactivating a participant restores them to rotation at their existing slot.
|
||||
- Toggle active never changes participant position or tie order.
|
||||
|
||||
Reason: auto-advancing from toggle active makes a status edit count like a turn pass,
|
||||
can wrap the round accidentally, and causes double-act/skip drift.
|
||||
|
||||
## Explicitly Forbidden
|
||||
|
||||
- `sort()` on every mutation. Overwrites drag order. Root cause of past drift.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Log System Redesign Plan
|
||||
|
||||
**Status:** DRAFT — not started
|
||||
**Branch:** `feat-turn-writes-logs`
|
||||
**Goal:** Lean mutation deltas. Kill bloated payload/undo duplication.
|
||||
|
||||
## Problem
|
||||
|
||||
Current events = 10-16KB each. `add_participant` row = 16KB to say "Fighter added, init 22." Root cause: full participant roster stored 3x (payload + undo_payload.updates + undo_payload.redo).
|
||||
|
||||
DB: 2486 docs, most bloated. Replay trace 1.6-5.5MB for ~250 steps.
|
||||
|
||||
Old log (pre-refactor) = message string + small undo object. Proven worked for undo/redo + viewer. Just wanted MORE STRUCTURE, not MORE DATA. Overbuilt instead.
|
||||
|
||||
## Intention
|
||||
|
||||
Each event = mutation delta + lean snapshot. Structured for filter/group/analyzer. No roster arrays.
|
||||
|
||||
### Canonical lean event shape
|
||||
|
||||
```js
|
||||
{
|
||||
id, ts, type, // identity + filter key
|
||||
message, // human text (viewer displays)
|
||||
encounterId, encounterName, // which encounter
|
||||
participantId, participantName, // who (nullable: pause/nextTurn have none)
|
||||
// type-specific scalar delta (one of these clusters per type):
|
||||
// add: { init, maxHp, type: 'character'|'monster' }
|
||||
// remove: { dead: bool }
|
||||
// damage/heal:{ amount }
|
||||
// condition: { condition }
|
||||
// toggleActive:{ revive: bool }
|
||||
// reorder: { draggedId, targetId }
|
||||
// update: { changedFields: ['notes', ...] }
|
||||
// nextTurn: (none — snapshot carries new round/pointer)
|
||||
// pause/resume/start/end: (none)
|
||||
snapshot: { // lean, ALREADY GOOD — keep as-is
|
||||
round, currentTurnParticipantId, turnOrderIds, activeIds
|
||||
},
|
||||
undo: <type-specific inverse, id-based> // minimal
|
||||
}
|
||||
```
|
||||
|
||||
### Undo inverses (id-based, minimal)
|
||||
|
||||
| type | undo |
|
||||
|------|------|
|
||||
| add_participant | `{ participantId }` → removeParticipant by id |
|
||||
| remove_participant | full participant obj (must restore — only place full obj needed) |
|
||||
| damage | `{ amount: -amount }` or heal back |
|
||||
| heal | `{ amount: -amount }` or damage back |
|
||||
| condition | `{ condition }` (toggle is its own inverse) |
|
||||
| toggleActive | `{ revive: !revive }` |
|
||||
| reorder | `{ draggedId, targetId }` (swap back) |
|
||||
| update | `{ changedFields: [...], oldValues: {...} }` |
|
||||
| nextTurn | `{ currentTurnParticipantId, round }` from snapshot delta |
|
||||
| pause/resume | toggle |
|
||||
| start/end | full prior scalar state |
|
||||
|
||||
**No `redo` field.** Redo = re-apply forward delta from event itself.
|
||||
|
||||
**No `undo_payload` wrapper.** Just `undo` object.
|
||||
|
||||
## 4 Consumers
|
||||
|
||||
1. **Viewer** — shows `message` + `type` + `participantName` + `undone` flag. Legacy logs (no type) display message only.
|
||||
2. **Undo/redo** — apply `undo` inverse by id. Id-based, minimal.
|
||||
3. **Replay** — turn.js funcs write own lean events. Trust func return. NO read-back. JSONL trace = lean snapshots + types.
|
||||
4. **Analyzer** — reads `snapshot` sequence + `type`. Reconstructs roster from event stream (add/remove by id). NO payload arrays needed.
|
||||
|
||||
## Legacy Support
|
||||
|
||||
- **Viewer:** yes — show old message-only logs, no type parse
|
||||
- **Undo:** no — old logs unundoable (acceptable, sit inert)
|
||||
- **Analyzer:** best-effort — snapshot may be absent on legacy → skip invariant check for that run
|
||||
|
||||
## Why Lean
|
||||
|
||||
- `add_participant` 16KB → ~150 bytes
|
||||
- Replay trace 5.5MB → ~250KB
|
||||
- Undo still works (proven old log did same)
|
||||
- Analyzer unaffected (uses snapshot+type, not payload)
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Redesign event writers (shared/turn.js)
|
||||
|
||||
- [ ] Rewrite `buildEntry()` to produce lean shape (delta fields per type)
|
||||
- [ ] Rewrite `commit()` — no patch storage, write lean event
|
||||
- [ ] Per-func: compute minimal delta + inverse from enc before/after
|
||||
- [ ] Drop `payload`, `undo_payload`, `undo_payload.redo`
|
||||
|
||||
### Phase 2: Update undo executor
|
||||
|
||||
- [ ] Find undo consumer (App.js `applyUndo` or similar)
|
||||
- [ ] Switch to id-based inverse application
|
||||
- [ ] Handle each type's undo shape
|
||||
- [ ] Drop `transactionalUndo` full-patch path if exists
|
||||
|
||||
### Phase 3: Update replay trace
|
||||
|
||||
- [ ] `replay-combat.js` callStep: trust return, snapshot only (no read-back)
|
||||
- [ ] Trace = `{step, ts, type, pre:leanSnap, post:leanSnap}` — no participant arrays
|
||||
|
||||
### Phase 4: Update analyzer
|
||||
|
||||
- [ ] `analyze-turns.js` already uses snapshot+type only — verify no payload dep
|
||||
- [ ] Reconstruct roster from event stream for skip/double-act checks (id-level)
|
||||
|
||||
### Phase 5: Legacy compat
|
||||
|
||||
- [ ] `logEvent.js normalizeEvent` — lift legacy into lean shape (type='legacy', message only)
|
||||
- [ ] Viewer handles legacy gracefully
|
||||
- [ ] Download = lean events only (legacy excluded or message-only)
|
||||
|
||||
### Phase 6: Cleanup
|
||||
|
||||
- [ ] Delete old bloated docs from DB? Or leave (inert, viewer reads message)?
|
||||
- [ ] Verify build CI=true passes
|
||||
- [ ] Verify analyzer clean on fresh replay
|
||||
- [ ] Verify undo works on new events
|
||||
|
||||
## NOT in scope
|
||||
|
||||
- Fixing 5 pre-existing initiative bugs (logged in TODO.md, separate)
|
||||
- Changing snapshot shape (already lean)
|
||||
- Changing viewer UI (just data source)
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Undo for `remove_participant`: must restore full obj (only place full obj needed). Acceptable?
|
||||
- Should legacy logs be deleted from DB or left inert?
|
||||
- Download format: lean JSON array (no legacy) — confirm
|
||||
+1
-1
@@ -118,7 +118,7 @@ node tests/audit/audit-rotation.js
|
||||
Runs pure turn.js combat. Audits 9 invariant classes per round:
|
||||
1. rotation integrity (skip/dupe)
|
||||
2. HP bounds (0 ≤ hp ≤ max, no NaN)
|
||||
3. isActive consistency (dead = inactive)
|
||||
3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
|
||||
4. turnOrder no dup ids
|
||||
5. turnOrder ids all active
|
||||
6. currentTurn valid + active
|
||||
|
||||
+4
-2
@@ -21,13 +21,15 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"build": "CI=true react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"test:ci": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false",
|
||||
"test:one": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false --testPathPattern",
|
||||
"eject": "react-scripts eject",
|
||||
"server:dev": "npm run dev --workspace server",
|
||||
"server:test": "npm test --workspace server",
|
||||
"shared:test": "npm test --workspace shared",
|
||||
"test:all": "CI=true react-scripts test --watchAll=false && npm run shared:test && npm run server:test"
|
||||
"test:all": "./scripts/run-tests.sh"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
||||
+415
-294
@@ -1,315 +1,436 @@
|
||||
// scripts/analyze-turns.js
|
||||
// Ingest replay-combat.js stdout (or any text matching its format), reconstruct
|
||||
// rounds, report real skips + double-acts. Deterministic — no eyeballing.
|
||||
// Invariant checker for combat rotation. Source-agnostic.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/analyze-turns.js [path] # analyze a saved log file
|
||||
// node scripts/replay-combat.js 100 100 | node scripts/analyze-turns.js
|
||||
// cat /tmp/replay.log | node scripts/analyze-turns.js
|
||||
// Input (autodetect):
|
||||
// .jsonl file — replay-combat trace: per-step {step,ts,type,call:{fn,args},
|
||||
// pre,post}. pre/post = backend read-back snapshots.
|
||||
// .json array — downloaded log OR exported events. {ts,type,...,snapshot}.
|
||||
// snapshot = what turn.js logged (lighter: no participants[]).
|
||||
// .log file — replay stdout: extract trace path from 'trace written:' line.
|
||||
// stdin — either jsonl or json.
|
||||
// no arg = usage. User must specify path.
|
||||
//
|
||||
// Skip = participant active for WHOLE round (never deactivated/removed mid-round
|
||||
// before their slot, never added mid-round) but never appeared as a turn actor.
|
||||
// Double-act = same participant takes 2+ turns in one round.
|
||||
// INVARIANTS (define correctness; no prediction):
|
||||
// 1. round monotonically ascends; +1 only on pointer wrap (last→first active).
|
||||
// No backward, no double-increment, no skip.
|
||||
// 2. pointer advances forward in turnOrderIds (mod wrap), skipping inactive.
|
||||
// Never backward, never stationary except on pause/non-rotation mutations.
|
||||
// 3. no double-act: in one rotation cycle each active participant becomes
|
||||
// current ≤1 time.
|
||||
// 4. no real skip: participant active for full cycle, never removed/deactivated,
|
||||
// but never became current = skipped.
|
||||
// 5. order stable across non-reorder mutations. turnOrderIds shift without
|
||||
// add/remove/reorder = display divergence.
|
||||
// 6. slot order (initiative desc, tie-break stable) maintained except after
|
||||
// explicit reorder. Replay-trace only (needs participants[].initiative).
|
||||
//
|
||||
// FEAT-2 (structured turn snapshot in app logs) will let this ingest live app
|
||||
// logs too, not just replay stdout. Format-agnostic core lives in parseReplay().
|
||||
// Exit 0 clean, 1 issues found.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// ---------- parsing ----------
|
||||
|
||||
const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|\s*order=\[(.*)\](?:\s*cur=.*)?)?\s*$/;
|
||||
const DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/;
|
||||
const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/;
|
||||
const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/;
|
||||
const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/;
|
||||
const PAUSE_RE = /^\s*\[pause\]\s*$/;
|
||||
const RESUME_RE = /^\s*\[resume\]\s*$/;
|
||||
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+(?:complete|starting)/;
|
||||
const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/;
|
||||
const REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/;
|
||||
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;
|
||||
|
||||
function parseLine(line) {
|
||||
if (TURN_RE.test(line)) {
|
||||
const m = line.match(TURN_RE);
|
||||
const orderStr = m[4] || '';
|
||||
// parse Name:init pairs
|
||||
const order = orderStr.split(',').map(s => s.trim()).filter(Boolean).map(pair => {
|
||||
const [name, init] = pair.split(':');
|
||||
return { name: name.trim(), init: init !== undefined ? +init : null };
|
||||
});
|
||||
return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim(), order };
|
||||
}
|
||||
if (FIRST_RE.test(line)) {
|
||||
const m = line.match(FIRST_RE);
|
||||
return { kind: 'turn', turn: 0, round: 1, actor: m[1].trim() };
|
||||
}
|
||||
if (DEACTIVATE_RE.test(line)) return { kind: 'deactivate', name: line.match(DEACTIVATE_RE)[1].trim() };
|
||||
if (REACTIVATE_RE.test(line)) return { kind: 'reactivate', name: line.match(REACTIVATE_RE)[1].trim() };
|
||||
if (ADD_RE.test(line)) return { kind: 'add', name: line.match(ADD_RE)[1].trim() };
|
||||
if (REMOVE_RE.test(line)) return { kind: 'remove', name: line.match(REMOVE_RE)[1].trim() };
|
||||
if (PAUSE_RE.test(line)) return { kind: 'pause' };
|
||||
if (RESUME_RE.test(line)) return { kind: 'resume' };
|
||||
if (POINTER_RE.test(line)) {
|
||||
const m = line.match(POINTER_RE);
|
||||
return { kind: 'pointer', from: m[1].trim(), to: m[2].trim(), wrap: m[3] === ' wrap' };
|
||||
}
|
||||
if (REORDER_RE.test(line)) {
|
||||
const m = line.match(REORDER_RE);
|
||||
return { kind: 'reorder', dragged: m[1].trim(), target: m[2].trim() };
|
||||
}
|
||||
if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- reconstruction ----------
|
||||
|
||||
// Build per-round timeline: round -> { turns: [actor], mutations: [{stepIdx,...}] }
|
||||
// Then compute skips + double-acts.
|
||||
function reconstruct(events) {
|
||||
// global state: active set by name. Start populated lazily from first turn.
|
||||
const active = new Set();
|
||||
const rounds = new Map(); // round -> { turns: [name], events: [{...}] }
|
||||
let curRound = 1;
|
||||
let sawFirstTurn = false;
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'turn') {
|
||||
sawFirstTurn = true;
|
||||
curRound = ev.round;
|
||||
if (!rounds.has(curRound)) rounds.set(curRound, { turns: [], events: [], complete: false });
|
||||
const r = rounds.get(curRound);
|
||||
r.turns.push(ev.actor);
|
||||
r.events.push({ ...ev, idx: r.events.length });
|
||||
if (!active.has(ev.actor)) active.add(ev.actor); // first sighting = active
|
||||
} else if (ev.kind === 'deactivate') {
|
||||
active.delete(ev.name);
|
||||
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||
r.events.push({ ...ev, idx: r.events.length });
|
||||
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
|
||||
active.add(ev.name);
|
||||
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||
r.events.push({ ...ev, idx: r.events.length });
|
||||
} else if (ev.kind === 'remove') {
|
||||
active.delete(ev.name);
|
||||
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||
r.events.push({ ...ev, idx: r.events.length });
|
||||
} else if (ev.kind === 'pointer') {
|
||||
// wrap pointer advances to next round — credit there.
|
||||
if (ev.wrap) curRound += 1;
|
||||
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||
r.events.push({ ...ev, idx: r.events.length });
|
||||
} else if (ev.kind === 'reorder') {
|
||||
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||
r.events.push({ ...ev, idx: r.events.length });
|
||||
} else if (ev.kind === 'round-complete') {
|
||||
if (rounds.has(ev.round)) rounds.get(ev.round).complete = true;
|
||||
}
|
||||
// pause/resume: rotation-affecting but no roster change; tracked in events
|
||||
else if (ev.kind === 'pause' || ev.kind === 'resume') {
|
||||
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||
r.events.push({ ...ev, idx: r.events.length });
|
||||
}
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
// For each round, recompute active-at-start and acted, then find real skips.
|
||||
function analyze(rounds) {
|
||||
const report = [];
|
||||
for (const [roundN, r] of [...rounds.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
// Replay stdout doesn't dump roster, so infer "active at round start":
|
||||
// walk events IN ORDER, snapshot active set at first turn of this round.
|
||||
// We replay from a clean per-round pass using a carry-over active set.
|
||||
report.push(analyzeRound(roundN, r));
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
// Re-run per-round with active-set carry-over across rounds (module scope).
|
||||
function analyzeRounds(rounds) {
|
||||
// Carry active set + current-name forward round to round.
|
||||
let activeCarry = new Set();
|
||||
let currentCarry = null;
|
||||
const reports = [];
|
||||
const sortedRounds = [...rounds.entries()].sort((a, b) => a[0] - b[0]);
|
||||
for (const [roundN, r] of sortedRounds) {
|
||||
if (!r.complete) continue; // incomplete final round — can't judge skips
|
||||
if (roundN === 1) { activeCarry = new Set(); currentCarry = null; }
|
||||
const result = analyzeRoundWithCarry(roundN, r, activeCarry, currentCarry);
|
||||
reports.push(result.report);
|
||||
activeCarry = result.activeAfter;
|
||||
currentCarry = result.currentAfter;
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
// When current participant is deactivated/removed, code advances current to
|
||||
// next active. That target gets the turn pointer = acts. Parser can't see
|
||||
// roster/order from stdout, so on deact-current the NEXT turn actor is the
|
||||
// advance target and is credited an extra "pointer turn" (not a logged turn).
|
||||
function analyzeRoundWithCarry(roundN, r, activeAtStart, currentAtStart) {
|
||||
// activeAtStart: Set copy. Mutations during round adjust a working copy.
|
||||
const active = new Set(activeAtStart);
|
||||
const activeWholeRound = new Set(activeAtStart); // participants never toggled off/removed
|
||||
const addedThisRound = new Set();
|
||||
const turns = []; // ordered actor names (logged)
|
||||
const pointerTurns = new Set(); // names that got the turn pointer this round
|
||||
let current = currentAtStart; // current participant name (carry)
|
||||
|
||||
for (const ev of r.events) {
|
||||
if (ev.kind === 'turn') {
|
||||
turns.push(ev.actor);
|
||||
pointerTurns.add(ev.actor);
|
||||
if (!active.has(ev.actor)) active.add(ev.actor); // first-ever sighting
|
||||
current = ev.actor;
|
||||
} else if (ev.kind === 'pointer') {
|
||||
// mutation advanced current pointer: ev.to now holds it = got the turn.
|
||||
// Credit ev.to. Update tracking.
|
||||
pointerTurns.add(ev.to);
|
||||
current = ev.to;
|
||||
} else if (ev.kind === 'deactivate' || ev.kind === 'remove') {
|
||||
// deact/REMOVE of current → code auto-advances (emitted as pointer line).
|
||||
// Disqualify from whole-round (roster mutation = not "whole round").
|
||||
activeWholeRound.delete(ev.name);
|
||||
active.delete(ev.name);
|
||||
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
|
||||
activeWholeRound.delete(ev.name);
|
||||
active.add(ev.name);
|
||||
}
|
||||
}
|
||||
|
||||
// acted = names that took a turn OR got pointer via mutation-advance
|
||||
// (deact/remove of current advances to target — that target acts).
|
||||
// Pointer lines from replay tell us the target explicitly.
|
||||
const acted = new Set([...turns, ...pointerTurns]);
|
||||
|
||||
// double-acts: logged turns with count > 1 (pointer-credits excluded —
|
||||
// a deact-advance target acting once via pointer then once via nextTurn
|
||||
// is correct, not a bug).
|
||||
const counts = {};
|
||||
for (const n of turns) counts[n] = (counts[n] || 0) + 1;
|
||||
const doubleActs = Object.entries(counts).filter(([_, c]) => c > 1).map(([n, c]) => ({ name: n, count: c }));
|
||||
|
||||
// real skip: active for WHOLE round (no roster mutation) AND never got
|
||||
// turn/pointer. Mutations disqualify from whole-round already.
|
||||
const realSkips = [...activeWholeRound].filter(n => !acted.has(n));
|
||||
|
||||
return {
|
||||
report: {
|
||||
round: roundN,
|
||||
turnCount: turns.length,
|
||||
uniqueActors: acted.size,
|
||||
realSkips,
|
||||
doubleActs,
|
||||
turns,
|
||||
},
|
||||
activeAfter: active,
|
||||
currentAfter: current,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- order-shift detection ----------
|
||||
// Compare order+init between consecutive turn lines. Flag shifts NOT explained
|
||||
// by: logged reorder, add/remove (roster change), or initiative change.
|
||||
// DM drag-reorder = legit (logged reorder line). Phantom shifts = display/rotation
|
||||
// divergence bug (invariant: display === turnOrderIds === nextTurn).
|
||||
function detectOrderShifts(events) {
|
||||
const shifts = [];
|
||||
let prev = null;
|
||||
let prevTurnNo = null;
|
||||
// mutations since last turn (reorder/add/remove/reactivate/pointer)
|
||||
let pending = [];
|
||||
let initMap = {}; // name -> last known initiative
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'turn' && ev.order && ev.order.length) {
|
||||
const curNames = ev.order.map(o => o.name);
|
||||
const curInits = {};
|
||||
ev.order.forEach(o => { curInits[o.name] = o.init; });
|
||||
|
||||
if (prev) {
|
||||
const sameRoster = prev.length === curNames.length &&
|
||||
prev.every((n, i) => n === curNames[i]);
|
||||
if (!sameRoster) {
|
||||
// roster change (add/remove) — skip, expected order shift
|
||||
} else {
|
||||
// same roster, different order → explainable by reorder OR init change?
|
||||
const orderChanged = JSON.stringify(prev) !== JSON.stringify(curNames);
|
||||
const initChanged = ev.order.some(o => initMap[o.name] !== null && initMap[o.name] !== undefined && initMap[o.name] !== o.init);
|
||||
const hasReorder = pending.some(p => p.kind === 'reorder');
|
||||
if (orderChanged && !hasReorder && !initChanged) {
|
||||
shifts.push({ turn: ev.turn, from: prev, to: curNames, reason: 'no logged reorder/init change' });
|
||||
}
|
||||
}
|
||||
}
|
||||
prev = curNames;
|
||||
curInits && Object.keys(curInits).forEach(k => { initMap[k] = curInits[k]; });
|
||||
pending = [];
|
||||
prevTurnNo = ev.turn;
|
||||
} else if (ev.kind === 'reorder' || ev.kind === 'add' || ev.kind === 'remove' ||
|
||||
ev.kind === 'reactivate' || ev.kind === 'pointer') {
|
||||
pending.push(ev);
|
||||
}
|
||||
}
|
||||
return shifts;
|
||||
}
|
||||
|
||||
// ---------- CLI ----------
|
||||
// ---------- input ----------
|
||||
|
||||
function readInput() {
|
||||
const arg = process.argv[2];
|
||||
if (arg) return fs.readFileSync(arg, 'utf8');
|
||||
// stdin
|
||||
return fs.readFileSync(0, 'utf8');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const text = readInput();
|
||||
const lines = text.split('\n');
|
||||
const events = lines.map(parseLine).filter(Boolean);
|
||||
const rounds = reconstruct(events);
|
||||
const reports = analyzeRounds(rounds);
|
||||
|
||||
let totalSkips = 0;
|
||||
let totalDoubles = 0;
|
||||
const problemRounds = [];
|
||||
|
||||
for (const rep of reports) {
|
||||
const hasIssue = rep.realSkips.length > 0 || rep.doubleActs.length > 0;
|
||||
if (hasIssue) problemRounds.push(rep);
|
||||
totalSkips += rep.realSkips.length;
|
||||
totalDoubles += rep.doubleActs.length;
|
||||
// No arg + no stdin = usage.
|
||||
if (!arg && process.stdin.isTTY) {
|
||||
console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
|
||||
console.error(' cat events | node scripts/analyze-turns.js');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
for (const rep of problemRounds) {
|
||||
console.log(`R${rep.round}: turns=${rep.turnCount} unique=${rep.uniqueActors}`);
|
||||
if (rep.realSkips.length) console.log(` REAL SKIPS: ${rep.realSkips.join(', ')}`);
|
||||
if (rep.doubleActs.length) console.log(` DOUBLE-ACTS: ${rep.doubleActs.map(d => `${d.name}(${d.count}x)`).join(', ')}`);
|
||||
console.log(` sequence: ${rep.turns.join(' -> ')}`);
|
||||
}
|
||||
|
||||
// order-shift detection: flag unexplained display/rotation divergence
|
||||
const shifts = detectOrderShifts(events);
|
||||
if (shifts.length) {
|
||||
console.log(`\n--- order shifts (${shifts.length}) ---`);
|
||||
for (const s of shifts.slice(0, 10)) {
|
||||
console.log(` turn ${s.turn}: [${s.from.join(',')}] → [${s.to.join(',')}] (${s.reason})`);
|
||||
if (!arg) {
|
||||
const stdin = fs.readFileSync(0, 'utf8');
|
||||
if (!stdin.trim()) {
|
||||
console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
|
||||
console.error(' cat events | node scripts/analyze-turns.js');
|
||||
process.exit(2);
|
||||
}
|
||||
if (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`);
|
||||
return stdin;
|
||||
}
|
||||
|
||||
console.log(`\n=== ${reports.length} rounds analyzed ===`);
|
||||
console.log(`real skips: ${totalSkips}`);
|
||||
console.log(`double-acts: ${totalDoubles}`);
|
||||
console.log(`order shifts: ${shifts.length}`);
|
||||
const clean = totalSkips === 0 && totalDoubles === 0 && shifts.length === 0;
|
||||
console.log(clean ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND');
|
||||
// Replay stdout .log: extract trace path from `trace written:` line.
|
||||
if (/\.log$/i.test(arg)) {
|
||||
const logText = fs.readFileSync(arg, 'utf8');
|
||||
const m = logText.match(/trace written: \d+ steps -> (.+)$/m);
|
||||
if (m) {
|
||||
const tracePath = m[1].trim();
|
||||
if (fs.existsSync(tracePath)) {
|
||||
console.error(`[analyze] trace: ${tracePath}`);
|
||||
return fs.readFileSync(tracePath, 'utf8');
|
||||
}
|
||||
console.error(`[analyze] trace path from .log not found: ${tracePath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`[analyze] no 'trace written:' line in ${arg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.exit(clean ? 0 : 1);
|
||||
return fs.readFileSync(arg, 'utf8');
|
||||
}
|
||||
|
||||
main();
|
||||
// snake_case log type → camelCase fn (invariant checks match both shapes).
|
||||
// replay JSONL already camelCase; unchanged.
|
||||
function normalizeFn(fn) {
|
||||
if (!fn) return fn;
|
||||
if (!fn.includes('_')) return fn;
|
||||
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Parse input text → array of step-arrays (one per encounter for downloaded
|
||||
// logs, one for replay JSONL trace). Each step:
|
||||
// { step, ts, type, fn, args, pre, post, error }
|
||||
// JSONL: pre/post from backend read-back. JSON array: post=snapshot, no pre.
|
||||
function loadSteps(text) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// JSONL: one JSON obj per line (each starts '{' + parses standalone).
|
||||
const looksJsonl = (() => {
|
||||
const lines = trimmed.split('\n');
|
||||
if (lines.length < 2) return false;
|
||||
const first = lines[0].trim();
|
||||
const second = lines[1].trim();
|
||||
if (!first.startsWith('{') || !second.startsWith('{')) return false;
|
||||
try { JSON.parse(first); JSON.parse(second); return true; }
|
||||
catch { return false; }
|
||||
})();
|
||||
|
||||
if (looksJsonl) {
|
||||
const steps = trimmed.split('\n').filter(l => l.trim()).map(l => {
|
||||
const r = JSON.parse(l);
|
||||
return {
|
||||
step: r.step, ts: r.ts, type: r.type,
|
||||
fn: normalizeFn(r.call ? r.call.fn : r.type),
|
||||
args: r.call ? r.call.args : null,
|
||||
pre: r.pre || null, post: r.post || null, error: r.error || null,
|
||||
};
|
||||
});
|
||||
return [steps]; // single trace
|
||||
}
|
||||
|
||||
// JSON array. Downloaded logs may merge multiple encounters → split by id.
|
||||
const raw = JSON.parse(trimmed);
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
const groups = new Map(); // encounterId -> []
|
||||
for (const e of arr) {
|
||||
const key = e.encounterId || '_none_';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
}
|
||||
const out = [];
|
||||
const toSteps = (evs) => evs.map((e, i) => ({
|
||||
step: i + 1, ts: e.ts || 0, type: e.type, fn: normalizeFn(e.type), args: null,
|
||||
pre: null,
|
||||
post: e.snapshot ? {
|
||||
round: e.snapshot.round,
|
||||
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
|
||||
isStarted: true, isPaused: false,
|
||||
turnOrderIds: e.snapshot.turnOrderIds || [],
|
||||
activeIds: e.snapshot.activeIds || [],
|
||||
participants: null, // downloaded logs lack full participant roster
|
||||
} : null,
|
||||
error: null,
|
||||
}));
|
||||
for (const evs of groups.values()) {
|
||||
// Same encounterId may span multiple combat runs (restart via
|
||||
// start_encounter). Sub-split so each rotation cycle analyzed within one
|
||||
// continuous run. start_encounter = run boundary — BUT only flush if a
|
||||
// prior run already started (i.e. true restart). First start_encounter
|
||||
// after pure setup (add_participant etc.) stays with its setup events.
|
||||
let cur = [];
|
||||
let started = false;
|
||||
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
|
||||
for (const e of evs) {
|
||||
if (e.type === 'start_encounter' && started) flush();
|
||||
if (e.type === 'start_encounter') started = true;
|
||||
cur.push(e);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
const nameMap = new Map(); // id -> name (built lazily from snapshots)
|
||||
|
||||
function learnNames(steps) {
|
||||
for (const s of steps) {
|
||||
for (const snap of [s.pre, s.post]) {
|
||||
if (snap && Array.isArray(snap.participants)) {
|
||||
for (const p of snap.participants) if (p.id && p.name) nameMap.set(p.id, p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
|
||||
|
||||
// next active position after fromPos in order, skipping inactive. Mirrors
|
||||
// turn.js nextActiveAfter so we know what SHOULD have happened — but this is
|
||||
// invariant definition, not prediction: we check the ACTUAL post-current.
|
||||
function expectedAdvance(order, fromPos, isActive) {
|
||||
const n = order.length;
|
||||
if (n === 0) return { nextId: null, wrapped: false };
|
||||
for (let step = 1; step < n; step++) {
|
||||
const idx = (fromPos + step) % n;
|
||||
const id = order[idx];
|
||||
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
|
||||
}
|
||||
// solo active = stays itself (turn.js would throw; treat as no-advance)
|
||||
return { nextId: null, wrapped: false };
|
||||
}
|
||||
|
||||
// ---------- invariant checks ----------
|
||||
|
||||
// Split analysis into independent passes. Each invariant = own function.
|
||||
// Entangling cycle-skip tracking with per-step mutation handling caused
|
||||
// stale-set false positives (active-set rebuilt on every roster mutation
|
||||
// discarded the cycle-start snapshot).
|
||||
function analyze(steps) {
|
||||
const issues = [];
|
||||
const rounds = new Map();
|
||||
function ensureRound(r) {
|
||||
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, issues: [] });
|
||||
return rounds.get(r);
|
||||
}
|
||||
|
||||
// ---- per-step: round monotonic, advance direction, order stability ----
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||
const post = s.post;
|
||||
if (!post) continue;
|
||||
|
||||
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
|
||||
|
||||
if (isStart) { ensureRound(post.round || 1).turnCount++; continue; }
|
||||
if (isEnd) continue;
|
||||
if (isNextTurn) {
|
||||
ensureRound(post.round || 0).turnCount++;
|
||||
if (!pre) continue;
|
||||
|
||||
const order = pre.turnOrderIds || [];
|
||||
const fromPos = order.indexOf(pre.currentTurnParticipantId);
|
||||
const isActive = id => (pre.activeIds || []).includes(id);
|
||||
const exp = expectedAdvance(order, fromPos, isActive);
|
||||
const actual = post.currentTurnParticipantId;
|
||||
|
||||
// invariant 2: correct advance target
|
||||
if (exp.nextId && actual && actual !== exp.nextId) {
|
||||
issues.push({ step: s.step, round: post.round, kind: 'wrong_advance',
|
||||
expected: nm(exp.nextId), actual: nm(actual),
|
||||
detail: `nextTurn → ${nm(actual)}, expected ${nm(exp.nextId)}` });
|
||||
}
|
||||
// invariant 1: round monotonic + no phantom/skip
|
||||
if (pre.round !== undefined && post.round !== undefined) {
|
||||
if (post.round < pre.round)
|
||||
issues.push({ step: s.step, kind: 'round_backward', from: pre.round, to: post.round,
|
||||
detail: `round backward ${pre.round}→${post.round}` });
|
||||
if (post.round > pre.round + 1)
|
||||
issues.push({ step: s.step, kind: 'round_skip', from: pre.round, to: post.round,
|
||||
detail: `round jumped ${pre.round}→${post.round}` });
|
||||
if (post.round === pre.round + 1 && !exp.wrapped)
|
||||
issues.push({ step: s.step, kind: 'round_phantom', from: pre.round, to: post.round,
|
||||
detail: `round incremented without pointer wrap` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// non-rotation mutation: invariant 5 order stability
|
||||
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
|
||||
const before = JSON.stringify(pre.turnOrderIds || []);
|
||||
const after = JSON.stringify(post.turnOrderIds || []);
|
||||
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
|
||||
issues.push({ step: s.step, kind: 'order_shift', fn: s.fn,
|
||||
detail: `turnOrderIds changed without add/remove/reorder (${s.fn})` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dedicated cycle pass: skip + double-act (invariants 3+4) ----
|
||||
// Cycle = all-act-once between pointer wraps. Snapshot active-set at cycle
|
||||
// start. Track removals/deactivations as legitimate disqualifications.
|
||||
// Skip = in start-set, never disqualified, never acted.
|
||||
issues.push(...checkCycles(steps));
|
||||
|
||||
return { issues, rounds };
|
||||
}
|
||||
|
||||
// checkCycles: walk steps, maintain rotation cycle state. On wrap/end,
|
||||
// finalize: skip = activeAtStart minus (acted ∪ disqualified). Double-act =
|
||||
// current that became current >1 in cycle (excluding the legit starter).
|
||||
function checkCycles(steps) {
|
||||
const out = [];
|
||||
let cycleActive = new Set(); // snapshot at cycle start (immutable for cycle)
|
||||
let cycleActed = new Set();
|
||||
let cycleRemoved = new Set(); // removed/disqualified mid-cycle (no skip flag)
|
||||
let cycleStarter = null;
|
||||
let cycleRound = null;
|
||||
let started = false;
|
||||
|
||||
// disqualify by active-set delta, not fn name. Log types vary (deactivate,
|
||||
// reactivate, remove_participant, add_participant...). Any step where an id
|
||||
// leaves activeIds = disqualified. Any id entering = joins cycle.
|
||||
function finalize(endStep) {
|
||||
if (!started) return;
|
||||
// disqualify: anyone removed/deactivated mid-cycle is gone (legit)
|
||||
// skip = was active at start, never acted, never disqualified
|
||||
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
|
||||
if (skipped.length) {
|
||||
out.push({ step: endStep, round: cycleRound, kind: 'real_skip',
|
||||
actors: skipped.map(nm), detail: `active full cycle, never acted` });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||
const post = s.post;
|
||||
if (!post) continue;
|
||||
|
||||
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
|
||||
|
||||
if (isStart) {
|
||||
finalize(s.step);
|
||||
cycleRound = post.round || 1;
|
||||
cycleActive = new Set(post.activeIds || []);
|
||||
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||
cycleStarter = post.currentTurnParticipantId;
|
||||
started = true;
|
||||
continue;
|
||||
}
|
||||
if (isEnd) { started = false; continue; } // end: abandon cycle, no skip verdict (incomplete)
|
||||
|
||||
// CRITICAL: pointer (currentTurnParticipantId) changes via BOTH nextTurn
|
||||
// AND mutation-advance (toggleActive/remove of current auto-advances via
|
||||
// computeTurnOrderAfterRemoval). Any new current = got the turn = acted.
|
||||
// Only count this on nextTurn (normal) or when a mutation actually moved
|
||||
// the pointer (pre.current != post.current).
|
||||
if (started && pre && post.currentTurnParticipantId &&
|
||||
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
|
||||
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
|
||||
if (wrapped && isNextTurn) {
|
||||
finalize(s.step);
|
||||
cycleRound = post.round;
|
||||
cycleActive = new Set(post.activeIds || []);
|
||||
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||
cycleStarter = post.currentTurnParticipantId;
|
||||
} else {
|
||||
const c = post.currentTurnParticipantId;
|
||||
if (cycleActed.has(c) && c !== cycleStarter) {
|
||||
out.push({ step: s.step, round: post.round, kind: 'double_act', actor: nm(c),
|
||||
detail: `${nm(c)} acted twice in round ${post.round} (via ${s.fn})` });
|
||||
}
|
||||
cycleActed.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNextTurn) continue;
|
||||
|
||||
// roster mutation mid-cycle: cycleActive = cycle-start snapshot (immutable).
|
||||
// invariant 4 = active FULL cycle → only removals disqualify (can't have
|
||||
// been full-cycle if removed). Mid-cycle additions don't qualify for skip
|
||||
// check, so never enroll them. Revivals: stay out (can act, not skip-flag).
|
||||
if (pre && post && pre.activeIds && post.activeIds) {
|
||||
const postSet = new Set(post.activeIds);
|
||||
for (const id of [...cycleActive]) {
|
||||
if (!postSet.has(id)) {
|
||||
cycleRemoved.add(id);
|
||||
cycleActive.delete(id);
|
||||
cycleActed.delete(id);
|
||||
}
|
||||
}
|
||||
// no mid-cycle enrollment: new ids weren't active at cycle start.
|
||||
}
|
||||
}
|
||||
// no final finalize: incomplete cycle can't be judged for skips.
|
||||
return out;
|
||||
}
|
||||
|
||||
// roster/order-affecting fns where turnOrderIds change is EXPECTED.
|
||||
// Handle both camelCase (replay trace fn) + snake_case (log type).
|
||||
function orderChangedByRosterOrReorder(fn) {
|
||||
return [
|
||||
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
|
||||
'startEncounter','endEncounter','setup_encounter','setup_campaign',
|
||||
'add_participant','add_participants','remove_participant','reorder',
|
||||
'start_encounter','end_encounter',
|
||||
].includes(fn);
|
||||
}
|
||||
|
||||
// slot order (invariant 6) — replay trace only (needs participants[].initiative)
|
||||
function checkSlotOrder(steps) {
|
||||
const violations = [];
|
||||
let prevOrder = null; // [{id,init}]
|
||||
let prevStep = 0;
|
||||
const orderAffecting = new Set(['addParticipant','addParticipants','removeParticipant',
|
||||
'reorderParticipants','startEncounter','setup_encounter','setup_campaign']);
|
||||
for (const s of steps) {
|
||||
if (!s.post || !Array.isArray(s.post.participants)) continue;
|
||||
const cur = s.post.participants.map(p => ({ id: p.id, init: p.initiative, name: p.name }));
|
||||
if (prevOrder && prevOrder.length === cur.length) {
|
||||
const sameIds = prevOrder.every((p, i) => p.id === cur[i].id);
|
||||
if (sameIds) {
|
||||
// same roster, same order — check initiative monotonic desc with stable ties
|
||||
for (let i = 1; i < cur.length; i++) {
|
||||
if (cur[i].initiative > cur[i - 1].initiative) {
|
||||
// initiative ascended — only ok if a reorder happened
|
||||
if (!orderAffecting.has(s.fn)) {
|
||||
violations.push({ step: s.step, kind: 'slot_violation',
|
||||
at: i, prev: nm(cur[i-1].id)+':'+cur[i-1].init,
|
||||
cur: nm(cur[i].id)+':'+cur[i].init,
|
||||
detail: `initiative ascended without reorder` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prevOrder = cur;
|
||||
prevStep = s.step;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
// ---------- reporting ----------
|
||||
|
||||
function reportOne(label, steps) {
|
||||
learnNames(steps);
|
||||
const { issues, rounds } = analyze(steps);
|
||||
const slotViolations = checkSlotOrder(steps);
|
||||
const all = [...issues, ...slotViolations].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||||
const byKind = {};
|
||||
for (const it of all) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
|
||||
|
||||
console.log(`=== ${label} — ${steps.length} steps, ${rounds.size} rounds ===`);
|
||||
if (all.length === 0) {
|
||||
console.log('CLEAN');
|
||||
return 0;
|
||||
}
|
||||
console.log(`--- ${all.length} issues ---`);
|
||||
for (const k of Object.keys(byKind)) console.log(` ${k}: ${byKind[k]}`);
|
||||
for (const it of all.slice(0, 30)) {
|
||||
const where = it.round != null ? `R${it.round} ` : '';
|
||||
console.log(` step ${it.step} ${where}${it.kind}: ${it.detail || ''}`);
|
||||
}
|
||||
if (all.length > 30) console.log(` ... +${all.length - 30} more`);
|
||||
return all.length;
|
||||
}
|
||||
|
||||
const text = readInput();
|
||||
const allSteps = loadSteps(text); // array of step-arrays (one per encounter)
|
||||
let total = 0;
|
||||
for (let i = 0; i < allSteps.length; i++) {
|
||||
const label = allSteps.length > 1 ? `[encounter ${i + 1}/${allSteps.length}]` : 'trace';
|
||||
if (i > 0) console.log('');
|
||||
total += reportOne(label, allSteps[i]);
|
||||
}
|
||||
console.log(`\n=== ${allSteps.length} source(s), ${total} total issues ===`);
|
||||
process.exit(total === 0 ? 0 : 1);
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/cap.sh — wrap a command with hard timeout. Hang = FAIL.
|
||||
# Picks gtimeout (mac coreutils) or timeout (linux).
|
||||
# Usage: cap.sh <seconds> <cmd> [args...]
|
||||
set -euo pipefail
|
||||
|
||||
TIMEOUT_BIN=gtimeout
|
||||
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
|
||||
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
|
||||
echo "ERROR: need 'timeout' or 'gtimeout'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
secs="$1"; shift
|
||||
exec "$TIMEOUT_BIN" --signal=KILL "$secs" "$@"
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/combat.js
|
||||
// ONE tool. Replaces replay-combat.js + analyze-turns.js + replay-from-logs.js.
|
||||
//
|
||||
// Two modes (same binary):
|
||||
//
|
||||
// combat replay — drive a fresh combat through LIVE backend, write log
|
||||
// combat verify — read any log file, check combat rotated correctly
|
||||
//
|
||||
// WHY ONE TOOL:
|
||||
// Same data, same words. Old 3-tool split = format + naming nightmare.
|
||||
// Replay writes the log, verify reads it. Same log shape both ends.
|
||||
//
|
||||
// LOG FORMAT:
|
||||
// JSON array. Same as app download. One mental model. NOT jsonl.
|
||||
// Each entry = canonical lean event (shared/logEvent.js shape):
|
||||
// { id, ts, type, message, encounterId, encounterName, encounterPath,
|
||||
// participantId, participantName, delta, undo, undone, snapshot }
|
||||
// snapshot = { round, currentTurnParticipantId, turnOrderIds, activeIds }
|
||||
//
|
||||
// USAGE:
|
||||
// combat replay [rounds] [delayMs] --out <log.json> [-v]
|
||||
// combat verify <log.json>
|
||||
// cat events.json | combat verify
|
||||
//
|
||||
// EXIT:
|
||||
// replay: 0 success, 1 error, 2 bad args
|
||||
// verify: 0 all checks pass, 1 bugs found, 2 bad args/no input
|
||||
//
|
||||
// WHAT VERIFY CHECKS (in DM words, not internals):
|
||||
// - Round count go up correctly (no skip, no jump, no backward)
|
||||
// - Turn pass to right person each step
|
||||
// - Nobody act twice in same round
|
||||
// - Nobody get skipped who was active whole round
|
||||
// - Turn order stay stable (no random reshuffle)
|
||||
// - Initiative slot order hold (replay logs only — need full roster)
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const shared = require('../shared');
|
||||
const { normalizeEvent, serializeEvents } = shared.logEvent;
|
||||
|
||||
// =============================================================================
|
||||
// ARGS
|
||||
// =============================================================================
|
||||
|
||||
function parseArgs(argv) {
|
||||
const mode = argv[0];
|
||||
const rest = argv.slice(1);
|
||||
if (mode !== 'replay' && mode !== 'verify') return { mode: null };
|
||||
|
||||
const out = { mode, verbose: false, out: null, positional: [] };
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i];
|
||||
if (a === '-v' || a === '--verbose') { out.verbose = true; continue; }
|
||||
if (a === '--out' && rest[i + 1]) { out.out = rest[i + 1]; i++; continue; }
|
||||
out.positional.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usageReplay() {
|
||||
console.error('Usage: combat replay [rounds] [delayMs] --out <log.json> [-v]');
|
||||
console.error(' --out <log.json> REQUIRED. Path you control.');
|
||||
console.error(' rounds default 20, delayMs default 200');
|
||||
console.error(' -v / --verbose log every action per turn');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function usageVerify() {
|
||||
console.error('Usage: combat verify <log.json>');
|
||||
console.error(' cat events.json | combat verify');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.mode) {
|
||||
console.error('combat — unified replay + verify for the TTRPG combat tracker');
|
||||
console.error('');
|
||||
console.error('Usage:');
|
||||
console.error(' combat replay [rounds] [delayMs] --out <log.json> [-v]');
|
||||
console.error(' combat verify <log.json>');
|
||||
console.error(' cat events.json | combat verify');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REPLAY MODE
|
||||
// =============================================================================
|
||||
// Drive fresh combat through live backend. Each mutation = one lean log entry
|
||||
// written to --out path. DM-facing stdout: round headers + per-turn actions.
|
||||
// Same log shape as app download. verify reads it back.
|
||||
|
||||
if (args.mode === 'replay') {
|
||||
if (!args.out) usageReplay();
|
||||
require('./combat/replay.js')(args).catch(err => {
|
||||
console.error('replay failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VERIFY MODE
|
||||
// =============================================================================
|
||||
// Read a combat log (app download OR combat replay output). Check rotation
|
||||
// correctness. DM-facing output: combat name, rounds, per-round turn list,
|
||||
// checks pass/fail. No "mutation", "pointer", "invariant" in output.
|
||||
|
||||
if (args.mode === 'verify') {
|
||||
const logPath = args.positional[0];
|
||||
let text;
|
||||
if (logPath) {
|
||||
if (!fs.existsSync(logPath)) {
|
||||
console.error(`combat verify: file not found: ${logPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
text = fs.readFileSync(logPath, 'utf8');
|
||||
} else if (!process.stdin.isTTY) {
|
||||
text = fs.readFileSync(0, 'utf8');
|
||||
}
|
||||
if (!text || !text.trim()) usageVerify();
|
||||
|
||||
try {
|
||||
const result = require('./combat/verify.js')(text, { verbose: args.verbose });
|
||||
process.exit(result);
|
||||
} catch (err) {
|
||||
console.error('verify failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// scripts/combat/replay.js
|
||||
// Drive fresh combat through LIVE backend. Write lean log to --out path.
|
||||
// Same log shape as app download (JSON array of canonical events).
|
||||
//
|
||||
// DM-facing stdout: round headers + per-turn actions. No internals leak.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const shared = require('../../shared');
|
||||
const { normalizeEvent } = shared.logEvent;
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause, endEncounter,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
stabilizeParticipant, reviveParticipant,
|
||||
toggleCondition, reorderParticipants,
|
||||
} = shared;
|
||||
const { createServerStorage } = require('../../src/storage/server');
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||
|
||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||
const PUB = `artifacts/${APP_ID}/public/data`;
|
||||
const getPath = {
|
||||
campaigns: () => `${PUB}/campaigns`,
|
||||
campaign: (id) => `${PUB}/campaigns/${id}`,
|
||||
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
|
||||
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
|
||||
activeDisplay: () => `${PUB}/activeDisplay/status`,
|
||||
logs: () => `${PUB}/logs`,
|
||||
};
|
||||
|
||||
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
function buildRoster() {
|
||||
return [
|
||||
{ id: 'char-fighter', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ id: 'char-cleric', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ id: 'char-rogue', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
];
|
||||
}
|
||||
function buildMonsters() {
|
||||
return [
|
||||
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
|
||||
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
|
||||
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
|
||||
{ name: 'Wolf', maxHp: 40, initMod: 3 },
|
||||
{ name: 'Merchant', maxHp: 30, initMod: 0, asNpc: true },
|
||||
];
|
||||
}
|
||||
|
||||
const CONDITIONS = [
|
||||
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||
'frightened','grappled','incapacitated','invisible','paralyzed',
|
||||
'petrified','poisoned','prone','restrained','sapped','shield',
|
||||
'slowed','stunned','unconscious','vexed',
|
||||
];
|
||||
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
|
||||
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
|
||||
let condIdx = 0;
|
||||
|
||||
// Lean snapshot — verify needs rotation fields only.
|
||||
function snapshot(enc) {
|
||||
if (!enc) return null;
|
||||
return {
|
||||
round: enc.round ?? 0,
|
||||
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
function nameOf(enc, id) {
|
||||
if (!id || !enc) return '(none)';
|
||||
const p = (enc.participants || []).find(x => x.id === id);
|
||||
return p ? p.name : '(missing)';
|
||||
}
|
||||
|
||||
// resolve participantId + name from call args + encounter state
|
||||
let _encCache = null;
|
||||
function resolveId(a) {
|
||||
if (!a) return null;
|
||||
const name = a.target || a.participant || a.dragged || a.name;
|
||||
if (!name || !_encCache) return null;
|
||||
const p = (_encCache.participants || []).find(x => x.name === name);
|
||||
return p ? p.id : null;
|
||||
}
|
||||
function resolveName(a) {
|
||||
if (!a) return null;
|
||||
return a.target || a.participant || a.dragged || a.name || null;
|
||||
}
|
||||
|
||||
module.exports = async function replay(args) {
|
||||
// In pipelines, Ctrl-C often kills downstream (e.g. timestamper) first.
|
||||
// Then any later console.log hits EPIPE and process dies before cleanup.
|
||||
// Ignore broken output pipe so SIGINT cleanup can still end combat + verify.
|
||||
for (const s of [process.stdout, process.stderr]) {
|
||||
s.on('error', (err) => {
|
||||
if (err && err.code === 'EPIPE') return;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
const ROUNDS = parseInt(args.positional[0], 10);
|
||||
const DELAY = parseInt(args.positional[1], 10);
|
||||
const rounds = Number.isNaN(ROUNDS) ? 20 : ROUNDS;
|
||||
const delay = Number.isNaN(DELAY) ? 200 : DELAY;
|
||||
// Safety only. Roster grows via reinforcements, so fixed rounds*30 was too low
|
||||
// and killed long replays around 6000 events. Keep generous hard stop.
|
||||
const MAX_STEPS = Math.max(rounds * 250, 10000);
|
||||
const VERBOSE = args.verbose;
|
||||
|
||||
const runStamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-');
|
||||
const campaignId = crypto.randomUUID();
|
||||
const encounterId = crypto.randomUUID();
|
||||
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||
const activeDisplayPath = getPath.activeDisplay();
|
||||
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
|
||||
|
||||
// ensure parent dir exists
|
||||
const traceDir = path.dirname(args.out);
|
||||
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
||||
|
||||
const events = []; // collected lean events, flushed to --out at end
|
||||
let stepN = 0;
|
||||
let prevEnc = null;
|
||||
let interrupted = false;
|
||||
let finishing = false;
|
||||
const onSigint = () => {
|
||||
interrupted = true;
|
||||
console.error('\ninterrupted — ending combat and verifying partial log...');
|
||||
finishAndExit().catch(err => {
|
||||
console.error('interrupt cleanup failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
process.once('SIGINT', onSigint);
|
||||
|
||||
// callStep: trust func return (post), no per-step getDoc. Build lean event
|
||||
// shape = same as app download. Pre/post snapshot = ground truth.
|
||||
async function callStep(fn, argsObj, runner) {
|
||||
stepN++;
|
||||
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
|
||||
const preSnap = snapshot(encBefore);
|
||||
let result, threw = null;
|
||||
try { result = await runner(encBefore); }
|
||||
catch (e) { threw = e.message; }
|
||||
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
|
||||
prevEnc = encAfter;
|
||||
_encCache = encAfter;
|
||||
const postSnap = snapshot(encAfter);
|
||||
|
||||
events.push({
|
||||
id: crypto.randomUUID(),
|
||||
ts: Date.now(),
|
||||
type: fn,
|
||||
message: describe(fn, argsObj, encAfter),
|
||||
encounterId,
|
||||
encounterName: `Replay ${runStamp}`,
|
||||
encounterPath,
|
||||
participantId: resolveId(argsObj),
|
||||
participantName: resolveName(argsObj),
|
||||
delta: null,
|
||||
undo: null,
|
||||
undone: false,
|
||||
snapshot: postSnap,
|
||||
_pre: preSnap,
|
||||
_call: { fn, args: argsObj },
|
||||
_error: threw,
|
||||
});
|
||||
return { enc: encAfter, result, threw };
|
||||
}
|
||||
|
||||
async function finishAndExit() {
|
||||
if (finishing) return;
|
||||
finishing = true;
|
||||
process.removeListener('SIGINT', onSigint);
|
||||
|
||||
try {
|
||||
// end encounter, even on Ctrl-C, so live display/app is not left mid-combat
|
||||
const latest = await storage.getDoc(encounterPath);
|
||||
prevEnc = latest;
|
||||
if (latest && latest.isStarted) {
|
||||
await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
|
||||
}
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||
} catch (err) {
|
||||
console.error('cleanup warning:', err.message || err);
|
||||
}
|
||||
|
||||
const log = interrupted ? console.error : console.log;
|
||||
const clean = events.map(({ _pre, _call, _error, ...rest }) => rest);
|
||||
fs.writeFileSync(args.out, JSON.stringify(clean, null, 2));
|
||||
log(`log written: ${events.length} events -> ${args.out}`);
|
||||
log('');
|
||||
|
||||
log('--- verifying ---');
|
||||
const text = fs.readFileSync(args.out, 'utf8');
|
||||
const verify = require('./verify.js');
|
||||
const exit = verify(text, { verbose: VERBOSE, log });
|
||||
process.exit(exit);
|
||||
}
|
||||
|
||||
// human-readable message per action
|
||||
function describe(fn, a, enc) {
|
||||
switch (fn) {
|
||||
case 'setup_campaign': return 'Campaign created';
|
||||
case 'setup_encounter': return 'Encounter created';
|
||||
case 'addParticipant': return `${a.name} joined`;
|
||||
case 'startEncounter': return `Combat started — round 1, ${nameOf(enc, enc.currentTurnParticipantId)} first`;
|
||||
case 'nextTurn': return `Turn passed to ${nameOf(enc, enc.currentTurnParticipantId)}`;
|
||||
case 'applyHpChange':
|
||||
return a.changeType === 'heal'
|
||||
? `${a.target} healed +${a.amount}`
|
||||
: `${a.target} took ${a.amount} damage`;
|
||||
case 'toggleCondition': return `${a.participant} ${a.condition} toggled`;
|
||||
case 'updateParticipant': return `${a.participant} edited`;
|
||||
case 'deathSave': return `${a.participant} death save (${a.outcome})`;
|
||||
case 'toggleParticipantActive':
|
||||
return a.revive ? `${a.participant} reactivated` : `${a.participant} toggled`;
|
||||
case 'togglePause': return a.to === 'paused' ? 'Combat paused' : 'Combat resumed';
|
||||
case 'removeParticipant': return `${a.participant} removed`;
|
||||
case 'reorderParticipants': return `${a.dragged} moved before ${a.target}`;
|
||||
case 'endEncounter': return 'Combat ended';
|
||||
default: return fn;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`replay: ${rounds} rounds, ${delay}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
|
||||
|
||||
// --- setup ---
|
||||
await callStep('setup_campaign', { campaignId }, async () =>
|
||||
storage.setDoc(getPath.campaign(campaignId), {
|
||||
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
|
||||
players: buildRoster(),
|
||||
})
|
||||
);
|
||||
await callStep('setup_encounter', { encounterId }, async () =>
|
||||
storage.setDoc(encounterPath, {
|
||||
id: encounterId, name: `Replay ${runStamp}`, campaignId,
|
||||
participants: [], isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
for (const ch of buildRoster()) {
|
||||
const { participant } = buildCharacterParticipant(ch);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx));
|
||||
}
|
||||
for (const m of buildMonsters()) {
|
||||
const { participant } = buildMonsterParticipant(m);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx));
|
||||
}
|
||||
|
||||
// --- start combat ---
|
||||
let enc = await storage.getDoc(encounterPath);
|
||||
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
|
||||
enc = startRes.enc;
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
|
||||
console.log(`--- round ${enc.round} ---`);
|
||||
|
||||
let totalTurns = 0;
|
||||
let lastRound = enc.round;
|
||||
|
||||
// --- main loop: drive by real enc.round ---
|
||||
while (!interrupted && enc.isStarted && enc.round <= rounds && stepN < MAX_STEPS) {
|
||||
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
|
||||
totalTurns++;
|
||||
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
|
||||
|
||||
if (actor) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
|
||||
enc = (await callStep('applyHpChange',
|
||||
{ target: tgt.name, changeType: 'damage', amount: dmg },
|
||||
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 5 === 0) {
|
||||
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
|
||||
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
|
||||
enc = (await callStep('toggleCondition',
|
||||
{ participant: actor.name, condition: cond },
|
||||
(e) => toggleCondition(e, actor.id, cond, ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 11 === 0) {
|
||||
if (VERBOSE) console.log(` update ${actor.name} notes`);
|
||||
enc = (await callStep('updateParticipant',
|
||||
{ participant: actor.name, fields: ['notes'] },
|
||||
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
|
||||
}
|
||||
|
||||
if (actor.status === 'dying' && (actor.type === 'character' || actor.type === 'npc')) {
|
||||
const outcomes = ['success', 'fail', 'nat1', 'nat20'];
|
||||
const outcome = outcomes[totalTurns % outcomes.length];
|
||||
if (VERBOSE) console.log(` deathSave ${actor.name} ${outcome}`);
|
||||
const dsRes = await callStep('deathSave',
|
||||
{ participant: actor.name, outcome },
|
||||
async (e) => (await deathSave(e, actor.id, outcome, ctx)).enc);
|
||||
enc = dsRes.enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
|
||||
enc = (await callStep('toggleParticipantActive',
|
||||
{ participant: tgt.name },
|
||||
(e) => toggleParticipantActive(e, tgt.id, ctx))).enc;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTurns % 20 === 0 && enc.isPaused === false) {
|
||||
if (VERBOSE) console.log(` reinforcements: pause`);
|
||||
enc = (await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx))).enc;
|
||||
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
|
||||
if (VERBOSE) console.log(` add ${r.participant.name}`);
|
||||
enc = (await callStep('addParticipant',
|
||||
{ name: r.participant.name, reinforcement: true },
|
||||
(e) => addParticipant(e, r.participant, ctx))).enc;
|
||||
if (VERBOSE) console.log(` resume`);
|
||||
enc = (await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 13 === 0) {
|
||||
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
|
||||
if (dead) {
|
||||
if (VERBOSE) console.log(` remove ${dead.name}`);
|
||||
enc = (await callStep('removeParticipant',
|
||||
{ participant: dead.name, dead: true },
|
||||
(e) => removeParticipant(e, dead.id, ctx))).enc;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTurns % 17 === 0) {
|
||||
const order = enc.participants || [];
|
||||
if (order.length >= 2) {
|
||||
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
|
||||
if (b) {
|
||||
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
|
||||
enc = (await callStep('reorderParticipants',
|
||||
{ dragged: b.name, target: a.name },
|
||||
(e) => reorderParticipants(e, b.id, a.id, ctx))).enc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (interrupted) break;
|
||||
if (delay > 0) await sleep(delay);
|
||||
if (interrupted) break;
|
||||
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
if (VERBOSE) console.log(` -> nextTurn`);
|
||||
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
|
||||
enc = advRes.enc;
|
||||
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
|
||||
|
||||
// round wrap
|
||||
if (enc.isStarted && enc.round > lastRound) {
|
||||
if (enc.round > rounds) {
|
||||
// cap hit: the nextTurn that wrapped us here is a phantom — remove it
|
||||
// so verify doesn't show an incomplete round.
|
||||
events.pop();
|
||||
stepN--;
|
||||
break;
|
||||
}
|
||||
console.log(`--- round ${enc.round} ---`);
|
||||
lastRound = enc.round;
|
||||
const down = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of down) {
|
||||
if (interrupted) break;
|
||||
if (d.status === 'dead') {
|
||||
if (VERBOSE) console.log(` revive dead ${d.name}`);
|
||||
enc = (await callStep('reviveParticipant',
|
||||
{ participant: d.name, revive: true },
|
||||
(e) => reviveParticipant(e, d.id, ctx))).enc;
|
||||
}
|
||||
if (d.status === 'stable') {
|
||||
if (VERBOSE) console.log(` heal stable ${d.name} +${d.maxHp}`);
|
||||
enc = (await callStep('applyHpChange',
|
||||
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
|
||||
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
|
||||
}
|
||||
const latest = (enc.participants || []).find(p => p.id === d.id);
|
||||
if (latest && latest.isActive === false) {
|
||||
if (VERBOSE) console.log(` reactivate ${latest.name}`);
|
||||
enc = (await callStep('toggleParticipantActive',
|
||||
{ participant: latest.name, revive: true },
|
||||
(e) => toggleParticipantActive(e, latest.id, ctx))).enc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
}
|
||||
|
||||
console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`);
|
||||
|
||||
await finishAndExit();
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
// scripts/combat/verify.js
|
||||
// Read combat log (JSON array of lean events). Check rotation correctness.
|
||||
// DM-facing output: combat name, rounds, per-round turn list, checks pass/fail.
|
||||
//
|
||||
// No internal jargon in output. "Someone acted twice", "someone skipped",
|
||||
// "turn passed wrong way" — not "double_act", "real_skip", "wrong_advance".
|
||||
//
|
||||
// CHECKS (rules combat must obey):
|
||||
// 1. Round count go up correctly (no skip/jump/backward)
|
||||
// 2. Turn pass to right person each step
|
||||
// 3. Nobody act twice in same round
|
||||
// 4. Nobody get skipped who was active whole round
|
||||
// 5. Turn order stay stable (no random reshuffle)
|
||||
// 6. Initiative slot order hold (replay logs only — needs full roster)
|
||||
//
|
||||
// Return: 0 clean, 1 bugs found.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { normalizeEvent } = require('../../shared/logEvent');
|
||||
|
||||
// snake_case log type → camelCase fn (checks match both shapes)
|
||||
function normalizeFn(fn) {
|
||||
if (!fn) return fn;
|
||||
if (!fn.includes('_')) return fn;
|
||||
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Parse text → array of step-arrays (one per combat run).
|
||||
// Input: JSON array of lean events (app download OR combat replay output).
|
||||
// Split: by encounterId, then sub-split on start_encounter (restart boundary).
|
||||
// First start after setup stays with setup (not bogus "encounter 1").
|
||||
function loadSteps(text) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const raw = JSON.parse(trimmed);
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
const groups = new Map();
|
||||
for (const e of arr) {
|
||||
const key = e.encounterId || '_none_';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
}
|
||||
|
||||
const toSteps = (evs) => evs.map((e, i) => ({
|
||||
step: i + 1, ts: e.ts || 0, type: e.type,
|
||||
fn: normalizeFn(e.type),
|
||||
args: null,
|
||||
pre: null,
|
||||
post: e.snapshot ? {
|
||||
round: e.snapshot.round,
|
||||
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
|
||||
isStarted: true, isPaused: false,
|
||||
turnOrderIds: e.snapshot.turnOrderIds || [],
|
||||
activeIds: e.snapshot.activeIds || [],
|
||||
participants: null,
|
||||
} : null,
|
||||
message: e.message || '',
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const out = [];
|
||||
for (const evs of groups.values()) {
|
||||
let cur = [];
|
||||
let started = false;
|
||||
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
|
||||
for (const e of evs) {
|
||||
if (e.type === 'start_encounter' && started) flush();
|
||||
if (e.type === 'start_encounter') started = true;
|
||||
cur.push(e);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- name map ----------
|
||||
|
||||
const nameMap = new Map();
|
||||
function learnNames(steps) {
|
||||
for (const s of steps) {
|
||||
// snapshot has no roster; use participantName field on events + messages
|
||||
if (s.message) {
|
||||
// no-op; names come from post.currentTurnParticipantId via message ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback: ids unknown → short. (replay logs carry encounterName; app logs
|
||||
// carry participantName. We use message text + the turnOrderIds as-is.)
|
||||
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
|
||||
|
||||
// rebuild name map from raw events (participantName field)
|
||||
function learnNamesFromEvents(evs) {
|
||||
for (const e of evs) {
|
||||
if (e.participantId && e.participantName) nameMap.set(e.participantId, e.participantName);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- expected advance ----------
|
||||
|
||||
function expectedAdvance(order, fromPos, isActive) {
|
||||
const n = order.length;
|
||||
if (n === 0) return { nextId: null, wrapped: false };
|
||||
for (let step = 1; step < n; step++) {
|
||||
const idx = (fromPos + step) % n;
|
||||
const id = order[idx];
|
||||
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
|
||||
}
|
||||
return { nextId: null, wrapped: false };
|
||||
}
|
||||
|
||||
// ---------- checks ----------
|
||||
|
||||
function analyze(steps) {
|
||||
const issues = [];
|
||||
const rounds = new Map();
|
||||
function ensureRound(r) {
|
||||
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, actors: [] });
|
||||
return rounds.get(r);
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||
const post = s.post;
|
||||
if (!post) continue;
|
||||
|
||||
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
|
||||
|
||||
if (isStart) {
|
||||
ensureRound(post.round || 1).actors.push(post.currentTurnParticipantId);
|
||||
continue;
|
||||
}
|
||||
if (isEnd) continue;
|
||||
if (isNextTurn) {
|
||||
const r = ensureRound(post.round || 0);
|
||||
r.turnCount++;
|
||||
r.actors.push(post.currentTurnParticipantId);
|
||||
if (!pre) continue;
|
||||
|
||||
const order = pre.turnOrderIds || [];
|
||||
const fromPos = order.indexOf(pre.currentTurnParticipantId);
|
||||
const isActive = id => (pre.activeIds || []).includes(id);
|
||||
const exp = expectedAdvance(order, fromPos, isActive);
|
||||
const actual = post.currentTurnParticipantId;
|
||||
|
||||
if (exp.nextId && actual && actual !== exp.nextId) {
|
||||
issues.push({ step: s.step, round: post.round, kind: 'wrong_turn',
|
||||
detail: `Turn passed to ${nm(actual)}, should have been ${nm(exp.nextId)}` });
|
||||
}
|
||||
if (pre.round !== undefined && post.round !== undefined) {
|
||||
if (post.round < pre.round)
|
||||
issues.push({ step: s.step, kind: 'round_backward',
|
||||
detail: `Round went ${pre.round} → ${post.round}` });
|
||||
if (post.round > pre.round + 1)
|
||||
issues.push({ step: s.step, kind: 'round_jump',
|
||||
detail: `Round jumped ${pre.round} → ${post.round}` });
|
||||
if (post.round === pre.round + 1 && !exp.wrapped)
|
||||
issues.push({ step: s.step, kind: 'round_phantom',
|
||||
detail: `Round went ${pre.round} → ${post.round} but turn didn't wrap` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
|
||||
const before = JSON.stringify(pre.turnOrderIds || []);
|
||||
const after = JSON.stringify(post.turnOrderIds || []);
|
||||
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
|
||||
issues.push({ step: s.step, kind: 'order_shift',
|
||||
detail: `Turn order changed during ${s.fn}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...checkCycles(steps, rounds));
|
||||
return { issues, rounds };
|
||||
}
|
||||
|
||||
function checkCycles(steps, rounds) {
|
||||
const out = [];
|
||||
let cycleActive = new Set();
|
||||
let cycleActed = new Set();
|
||||
let cycleStarter = null;
|
||||
let cycleRound = null;
|
||||
let started = false;
|
||||
|
||||
function finalize(endStep) {
|
||||
if (!started) return;
|
||||
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
|
||||
if (skipped.length) {
|
||||
out.push({ step: endStep, round: cycleRound, kind: 'skipped',
|
||||
detail: `Never acted in round ${cycleRound}: ${skipped.map(nm).join(', ')}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||
const post = s.post;
|
||||
if (!post) continue;
|
||||
|
||||
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
|
||||
|
||||
if (isStart) {
|
||||
finalize(s.step);
|
||||
cycleRound = post.round || 1;
|
||||
cycleActive = new Set(post.activeIds || []);
|
||||
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||
cycleStarter = post.currentTurnParticipantId;
|
||||
started = true;
|
||||
continue;
|
||||
}
|
||||
if (isEnd) { started = false; continue; }
|
||||
|
||||
if (started && pre && post.currentTurnParticipantId &&
|
||||
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
|
||||
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
|
||||
if (wrapped) {
|
||||
// drain removed/inactive before finalizing so removed actors
|
||||
// aren't flagged as skipped.
|
||||
if (pre && pre.activeIds && post.activeIds) {
|
||||
const postSet = new Set(post.activeIds);
|
||||
for (const id of [...cycleActive]) {
|
||||
if (!postSet.has(id)) {
|
||||
cycleActive.delete(id);
|
||||
cycleActed.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
finalize(s.step);
|
||||
cycleRound = post.round;
|
||||
cycleActive = new Set(post.activeIds || []);
|
||||
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||
cycleStarter = post.currentTurnParticipantId;
|
||||
} else {
|
||||
const c = post.currentTurnParticipantId;
|
||||
if (cycleActed.has(c) && c !== cycleStarter) {
|
||||
out.push({ step: s.step, round: post.round, kind: 'acted_twice',
|
||||
detail: `${nm(c)} acted twice in round ${post.round}` });
|
||||
}
|
||||
cycleActed.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNextTurn) continue;
|
||||
|
||||
if (pre && post && pre.activeIds && post.activeIds) {
|
||||
const postSet = new Set(post.activeIds);
|
||||
for (const id of [...cycleActive]) {
|
||||
if (!postSet.has(id)) {
|
||||
cycleActive.delete(id);
|
||||
cycleActed.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function orderChangedByRosterOrReorder(fn) {
|
||||
return [
|
||||
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
|
||||
'startEncounter','endEncounter','setup_encounter','setup_campaign',
|
||||
'add_participant','add_participants','remove_participant','reorder',
|
||||
'start_encounter','end_encounter',
|
||||
].includes(fn);
|
||||
}
|
||||
|
||||
// ---------- reporting (DM-facing) ----------
|
||||
|
||||
const KIND_LABEL = {
|
||||
wrong_turn: 'Turn passed to wrong person',
|
||||
round_backward: 'Round count went backward',
|
||||
round_jump: 'Round count jumped',
|
||||
round_phantom: 'Round count changed without turn wrapping',
|
||||
order_shift: 'Turn order changed unexpectedly',
|
||||
skipped: 'Someone got skipped',
|
||||
acted_twice: 'Someone acted twice',
|
||||
slot_violation: 'Initiative order violated',
|
||||
};
|
||||
|
||||
function reportOne(label, steps, rawEventsForNames, opts = {}) {
|
||||
const log = opts.log || console.log;
|
||||
if (rawEventsForNames) learnNamesFromEvents(rawEventsForNames);
|
||||
const { issues, rounds } = analyze(steps);
|
||||
|
||||
// combat name
|
||||
const name = (rawEventsForNames && rawEventsForNames[0] && rawEventsForNames[0].encounterName) || 'Combat';
|
||||
log(`=== ${label}: ${name} ===`);
|
||||
|
||||
// Per-round turn list only in verbose mode. Long replays can print thousands
|
||||
// of names and swamp useful result output.
|
||||
const sortedRounds = [...rounds.keys()].sort((a, b) => a - b);
|
||||
if (opts.verbose) {
|
||||
for (const r of sortedRounds) {
|
||||
const info = rounds.get(r);
|
||||
log(` Round ${r}: ${info.actors.map(nm).join(' → ')}`);
|
||||
}
|
||||
}
|
||||
log(` (${steps.length} events, ${rounds.size} rounds${opts.verbose ? '' : ', use -v for per-round turns'})`);
|
||||
|
||||
if (issues.length === 0) {
|
||||
log(' ✓ PASS — combat rotated correctly');
|
||||
return 0;
|
||||
}
|
||||
log(` ✗ FAIL — ${issues.length} problem(s):`);
|
||||
const byKind = {};
|
||||
for (const it of issues) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
|
||||
for (const [k, n] of Object.entries(byKind)) {
|
||||
log(` ${KIND_LABEL[k] || k}: ${n}`);
|
||||
}
|
||||
for (const it of issues.slice(0, 15)) {
|
||||
const where = it.round != null ? `R${it.round} ` : '';
|
||||
log(` [${where}event ${it.step}] ${it.detail}`);
|
||||
}
|
||||
if (issues.length > 15) log(` ... +${issues.length - 15} more`);
|
||||
return issues.length;
|
||||
}
|
||||
|
||||
module.exports = function verify(text, opts = {}) {
|
||||
const log = opts.log || console.log;
|
||||
const allSteps = loadSteps(text);
|
||||
if (!allSteps.length) {
|
||||
log('No combat events found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// raw events grouped same way for name learning
|
||||
const raw = JSON.parse(text.trim());
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
const rawGroups = new Map();
|
||||
for (const e of arr) {
|
||||
const key = e.encounterId || '_none_';
|
||||
if (!rawGroups.has(key)) rawGroups.set(key, []);
|
||||
rawGroups.get(key).push(e);
|
||||
}
|
||||
const rawList = [...rawGroups.values()];
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < allSteps.length; i++) {
|
||||
const label = allSteps.length > 1 ? `[combat ${i + 1}/${allSteps.length}]` : 'Combat';
|
||||
if (i > 0) log('');
|
||||
total += reportOne(label, allSteps[i], rawList[i], opts);
|
||||
}
|
||||
log('');
|
||||
log(total === 0 ? 'CLEAN' : `${total} problem(s) found`);
|
||||
return total === 0 ? 0 : 1;
|
||||
};
|
||||
@@ -18,7 +18,7 @@ done
|
||||
# backend: better-sqlite3, :4001
|
||||
if ! lsof -ti :4001 >/dev/null 2>&1; then
|
||||
echo "starting backend :4001..."
|
||||
DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 \
|
||||
DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 ALLOW_DEV_ENDPOINTS=1 \
|
||||
nohup npm run server:dev > tmp/server.log 2>&1 &
|
||||
echo $! > tmp/server.pid
|
||||
else
|
||||
|
||||
+292
-299
@@ -1,379 +1,372 @@
|
||||
// scripts/replay-combat.js
|
||||
// Drive a full combat through the LIVE backend via the ws storage adapter
|
||||
// Drive a full combat through the LIVE backend via the server storage adapter
|
||||
// (same contract boundary as the App), so the player display window
|
||||
// (subscribed via WS) live-updates as combat progresses.
|
||||
// Uses shared/turn.js for all turn logic (same model as the UI).
|
||||
//
|
||||
// Coverage goals (rotate across rounds):
|
||||
// - nextTurn (every turn)
|
||||
// - applyHpChange damage + heal (varying magnitude)
|
||||
// - toggleCondition (all CONDITIONS at least once)
|
||||
// - toggleParticipantActive (mark inactive, later reactivate)
|
||||
// - deathSave (when a PC reaches 0 HP)
|
||||
// - addParticipant (reinforcements drop in)
|
||||
// - removeParticipant (dead monsters hauled off)
|
||||
// - updateParticipant (edit fields mid-combat)
|
||||
// - togglePause / resume
|
||||
// - reorderParticipants (initiative reorder)
|
||||
// - endEncounter (cleanup)
|
||||
// BLIND CALLER: this tool does NOT know the correct turn order. It hammers
|
||||
// nextTurn + mutations and trusts turn.js. An SEPARATE analyze pass inspects
|
||||
// what actually happened (invariants on backend read-back). Do not encode
|
||||
// expected-order logic here — that's circular.
|
||||
//
|
||||
// Run: node scripts/replay-combat.js [rounds] [delayMs]
|
||||
// rounds default 100, delayMs default 200
|
||||
|
||||
'use strict';
|
||||
// DRIVEN BY REAL enc.round (backend state), not a fake loop counter. Round
|
||||
// wraps when nextTurn's pointer advances last→first active.
|
||||
//
|
||||
// TRACE: writes JSONL to tmp/replay-{stamp}.jsonl — one record per call:
|
||||
// { step, ts, type, call:{fn,args}, pre, post }
|
||||
// pre/post = backend getDoc snapshots (independent of func return value).
|
||||
// This is ground truth. analyze-turns.js consumes it. The func return value
|
||||
// is "what turn.js claims"; read-back is "what the backend stored". Mismatch
|
||||
// = write bug.
|
||||
//
|
||||
// Coverage (rotates by step counter):
|
||||
// applyHpChange damage, toggleCondition, updateParticipant, deathSave,
|
||||
// toggleParticipantActive, pause/add/resume reinforcements,
|
||||
// removeParticipant dead, reorderParticipants same-init, nextTurn.
|
||||
//
|
||||
// Run: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v|--verbose]
|
||||
// rounds default 20, delayMs default 200
|
||||
// --out <path> = REQUIRED. Trace path you control.
|
||||
// -v / --verbose = log every action per turn
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const shared = require('../shared');
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
startEncounter, nextTurn, togglePause, endEncounter,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
toggleCondition, reorderParticipants,
|
||||
} = shared;
|
||||
const { createServerStorage } = require('../src/storage/server');
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||
const DELAY = parseInt(process.argv[3], 10) || 200;
|
||||
// args: [rounds] [delayMs] --out <path> [-v|--verbose]
|
||||
// --out <path> REQUIRED — trace path. You control it, not the tool.
|
||||
const args = process.argv.slice(2);
|
||||
const VERBOSE = args.some(a => a === '-v' || a === '--verbose');
|
||||
let OUT_PATH = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--out' && args[i + 1]) {
|
||||
OUT_PATH = args[i + 1];
|
||||
args.splice(i, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!OUT_PATH) {
|
||||
console.error('Usage: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v]');
|
||||
console.error(' --out <path> REQUIRED.');
|
||||
process.exit(2);
|
||||
}
|
||||
const positional = args.filter(a => !a.startsWith('-'));
|
||||
const ROUNDS = parseInt(positional[0], 10) || 20;
|
||||
const DELAY = parseInt(positional[1], 10) || 200;
|
||||
const MAX_STEPS = ROUNDS * 30; // safety cap on total calls
|
||||
|
||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||
const PUB = `artifacts/${APP_ID}/public/data`;
|
||||
// Mirror App.js getPath. Adapter takes these; norm() strips prefix.
|
||||
const getPath = {
|
||||
campaigns: () => `${PUB}/campaigns`,
|
||||
campaign: (id) => `${PUB}/campaigns/${id}`,
|
||||
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
|
||||
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
|
||||
activeDisplay: () => `${PUB}/activeDisplay/status`,
|
||||
logs: () => `${PUB}/logs`,
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// Use the ADAPTER as the contract boundary (same as App). No raw REST.
|
||||
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// Mirror App.js CONDITIONS so we exercise all of them.
|
||||
const CONDITIONS = [
|
||||
'alchemist_fire', 'bardic_inspiration', 'blinded', 'charmed', 'deafened',
|
||||
'exhaustion', 'frightened', 'grappled', 'grazed', 'incapacitated',
|
||||
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
||||
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
|
||||
];
|
||||
// Custom (freeform) condition ids — DM-added strings. toggleCondition must
|
||||
// accept ANY string (UI custom-condition contract). Exercise both paths.
|
||||
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
|
||||
|
||||
async function patch(encounterPath, enc, result, label) {
|
||||
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
|
||||
await storage.updateDoc(encounterPath, result.patch);
|
||||
if (label) console.log(` [${label}]`);
|
||||
// emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
|
||||
// nextTurn passes label=null — it's a normal advance, already logged via
|
||||
// the turn line. Emitting pointer for it double-counts.
|
||||
const oldCur = enc.currentTurnParticipantId;
|
||||
const oldRound = enc.round;
|
||||
const newEnc = { ...enc, ...result.patch };
|
||||
const newCur = newEnc.currentTurnParticipantId;
|
||||
const newRound = newEnc.round;
|
||||
if (label && oldCur && newCur && oldCur !== newCur) {
|
||||
const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
|
||||
const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
|
||||
const wrap = oldRound !== newRound ? ' wrap' : '';
|
||||
console.log(` [pointer ${oldName}→${newName}${wrap}]`);
|
||||
}
|
||||
return newEnc;
|
||||
function buildRoster() {
|
||||
return [
|
||||
{ name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
];
|
||||
}
|
||||
function buildMonsters() {
|
||||
return [
|
||||
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
|
||||
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
|
||||
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
|
||||
{ name: 'Wolf', maxHp: 40, initMod: 3 },
|
||||
{ name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true },
|
||||
];
|
||||
}
|
||||
|
||||
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
const CONDITIONS = [
|
||||
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||
'frightened','grappled','incapacitated','invisible','paralyzed',
|
||||
'petrified','poisoned','prone','restrained','sapped','shield',
|
||||
'slowed','stunned','unconscious','vexed',
|
||||
];
|
||||
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
|
||||
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
|
||||
let condIdx = 0;
|
||||
|
||||
// ---------- JSONL trace ----------
|
||||
|
||||
// Lean snapshot — analyzer needs rotation fields only, not roster.
|
||||
// participants dropped (was 80% of trace bloat). turnOrderIds + activeIds
|
||||
// carry id-level info; hp/conditions stay on encounter doc, not trace.
|
||||
function snapshot(enc) {
|
||||
if (!enc) return null;
|
||||
return {
|
||||
round: enc.round ?? 0,
|
||||
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||
isStarted: !!enc.isStarted,
|
||||
isPaused: !!enc.isPaused,
|
||||
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
function nameOf(enc, id) {
|
||||
if (!id || !enc) return '(none)';
|
||||
const p = (enc.participants || []).find(x => x.id === id);
|
||||
return p ? p.name : '(missing)';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
|
||||
|
||||
const runStamp = new Date().toISOString().slice(0,19).replace('T', '_').replace(/:/g, '-');
|
||||
const campaignId = crypto.randomUUID();
|
||||
const encounterId = crypto.randomUUID();
|
||||
|
||||
await storage.setDoc(getPath.campaign(campaignId), {
|
||||
name: `Replay Campaign (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
||||
playerDisplayBackgroundUrl: '',
|
||||
ownerId: 'replay',
|
||||
createdAt: new Date().toISOString(),
|
||||
players: [
|
||||
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
const charSpecs = [
|
||||
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
];
|
||||
const monsterSpecs = [
|
||||
{ name: 'Goblin1', maxHp: 100, initMod: 2 },
|
||||
{ name: 'Goblin2', maxHp: 100, initMod: 2 },
|
||||
{ name: 'OrcBoss', maxHp: 500, initMod: 1 },
|
||||
{ name: 'Wolf', maxHp: 120, initMod: 3 },
|
||||
{ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true },
|
||||
];
|
||||
|
||||
const participants = [
|
||||
...charSpecs.map(c => buildCharacterParticipant(c).participant),
|
||||
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
|
||||
];
|
||||
|
||||
await storage.setDoc(getPath.encounter(campaignId, encounterId), {
|
||||
name: `Big Boss Replay (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
||||
campaignId,
|
||||
createdAt: new Date().toISOString(),
|
||||
participants,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
turnOrderIds: [],
|
||||
});
|
||||
|
||||
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
|
||||
|
||||
await storage.setDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: campaignId,
|
||||
activeEncounterId: encounterId,
|
||||
hidePlayerHp: false,
|
||||
});
|
||||
await sleep(800);
|
||||
|
||||
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||
const activeDisplayPath = getPath.activeDisplay();
|
||||
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
|
||||
|
||||
// start
|
||||
let enc = await storage.getDoc(encounterPath);
|
||||
enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter');
|
||||
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
|
||||
await sleep(DELAY);
|
||||
let tracePath = OUT_PATH;
|
||||
// ensure parent dir exists
|
||||
const traceDir = path.dirname(tracePath);
|
||||
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
||||
const jsonl = fs.createWriteStream(tracePath, { flags: 'w' });
|
||||
|
||||
let stepN = 0;
|
||||
let prevEnc = null; // cached post = next step's pre (no backend read-back).
|
||||
// callStep: pre = cached prevEnc (or fresh read on first) → run → post =
|
||||
// runner return (trusted, like old fast impl) → emit JSONL. No per-step
|
||||
// backend getDoc. Funcs return newEnc already.
|
||||
async function callStep(fn, args, runner) {
|
||||
stepN++;
|
||||
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
|
||||
const pre = snapshot(encBefore);
|
||||
let result, threw = null;
|
||||
try { result = await runner(encBefore); }
|
||||
catch (e) { threw = e.message; }
|
||||
// func returns newEnc (post). Trust it — matches old fast behavior.
|
||||
// Setup funcs (setDoc/getDoc) return undefined → keep encBefore.
|
||||
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
|
||||
prevEnc = encAfter;
|
||||
const post = snapshot(encAfter);
|
||||
jsonl.write(JSON.stringify({
|
||||
step: stepN, ts: Date.now(), type: fn, call: { fn, args },
|
||||
pre, post, error: threw,
|
||||
}) + '\n');
|
||||
return { enc: encAfter, result, threw };
|
||||
}
|
||||
|
||||
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
|
||||
|
||||
// --- setup: campaign + encounter docs (also traced, type 'setup') ---
|
||||
await callStep('setup_campaign', { campaignId }, async () =>
|
||||
storage.setDoc(getPath.campaign(campaignId), {
|
||||
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
await callStep('setup_encounter', { encounterId }, async () =>
|
||||
storage.setDoc(encounterPath, {
|
||||
id: encounterId,
|
||||
name: `Big Boss Replay ${runStamp}`,
|
||||
campaignId,
|
||||
participants: [],
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
// add roster + monsters
|
||||
for (const ch of buildRoster()) {
|
||||
const { participant } = buildCharacterParticipant(ch);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx)
|
||||
);
|
||||
}
|
||||
for (const m of buildMonsters()) {
|
||||
const { participant } = buildMonsterParticipant(m);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx)
|
||||
);
|
||||
}
|
||||
|
||||
// start combat
|
||||
let enc = (await storage.getDoc(encounterPath));
|
||||
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
|
||||
enc = startRes.enc;
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
|
||||
console.log(`combat started: round ${enc.round}, first=${nameOf(enc, enc.currentTurnParticipantId)}`);
|
||||
|
||||
let totalTurns = 0;
|
||||
const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS].sort(() => Math.random() - 0.5);
|
||||
let reinforcementsAdded = 0;
|
||||
let lastPaused = false;
|
||||
let lastReorder = 0;
|
||||
let lastRound = enc.round;
|
||||
|
||||
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
console.log(`--- round ${roundN} starting ---`);
|
||||
// advance initiative until round counter ticks (full cycle done).
|
||||
const cap = (enc.participants.length + 2) * 2;
|
||||
let guard = 0;
|
||||
while (enc.round < roundN + 1 && guard < cap) {
|
||||
// NOTE: do NOT getDoc here — async re-fetch can return stale state and
|
||||
// cause nextTurn to compute off pre-mutation data (double-acts/skips).
|
||||
// Trust the local enc returned by patch (sync spread of updateDoc).
|
||||
// --- main loop: drive by real enc.round ---
|
||||
while (enc.isStarted && enc.round <= ROUNDS && stepN < MAX_STEPS) {
|
||||
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
|
||||
totalTurns++;
|
||||
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
|
||||
|
||||
// 9. resume if paused: must happen BEFORE nextTurn or it throws.
|
||||
if (lastPaused) {
|
||||
enc = await patch(encounterPath, enc, togglePause(enc), 'resume');
|
||||
lastPaused = false;
|
||||
if (actor) {
|
||||
// random damage from actor to random living target
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
|
||||
const res = await callStep('applyHpChange',
|
||||
{ target: tgt.name, changeType: 'damage', amount: dmg },
|
||||
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
let t;
|
||||
try { t = nextTurn(enc); } catch (e) { console.log(` nextTurn err: ${e.message}`); break; }
|
||||
enc = await patch(encounterPath, enc, t, null);
|
||||
totalTurns++;
|
||||
const actorName = firstActiveName(enc);
|
||||
const actor = currentParticipant(enc);
|
||||
|
||||
// Dump turn line with order AND initiative (DM drag may reorder without
|
||||
// changing init — log both so parser can flag unexplained shifts).
|
||||
const ordStr = enc.turnOrderIds.map(id => {
|
||||
const p = enc.participants.find(x => x.id === id);
|
||||
return p ? `${p.name}:${p.initiative}` : id;
|
||||
}).join(',');
|
||||
// Also dump participants[] order (display source). Diverge from order = sync bug.
|
||||
const pStr = enc.participants.map(p => `${p.name}:${p.initiative}`).join(',');
|
||||
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] parts=[${pStr}] cur=${enc.currentTurnParticipantId}`);
|
||||
|
||||
// 1. damage: actor hits a random living, active target.
|
||||
if (actor) {
|
||||
const foes = enc.participants.filter(
|
||||
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false && !p.name.startsWith('Dead')
|
||||
);
|
||||
if (foes.length > 0) {
|
||||
const tgt = pick(foes);
|
||||
const dmg = 1 + Math.floor(Math.random() * 5); // 1-5
|
||||
const h = applyHpChange(enc, tgt.id, 'damage', dmg);
|
||||
if (h.patch) {
|
||||
await storage.updateDoc(encounterPath, h.patch);
|
||||
enc = { ...enc, ...h.patch };
|
||||
console.log(` ${actorName} → ${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`);
|
||||
}
|
||||
}
|
||||
// random condition
|
||||
if (totalTurns % 5 === 0) {
|
||||
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
|
||||
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
|
||||
const res = await callStep('toggleCondition',
|
||||
{ participant: actor.name, condition: cond },
|
||||
(e) => toggleCondition(e, actor.id, cond, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 2. heal: Cleric (when active) heals lowest-HP ally every other turn.
|
||||
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||
const wounded = enc.participants
|
||||
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
|
||||
.sort((a, b) => (a.currentHp / a.maxHp) - (b.currentHp / b.maxHp));
|
||||
if (wounded.length > 0) {
|
||||
const tgt = wounded[0];
|
||||
const amt = 2 + Math.floor(Math.random() * 5); // 2-6
|
||||
const h = applyHpChange(enc, tgt.id, 'heal', amt);
|
||||
if (h.patch) {
|
||||
await storage.updateDoc(encounterPath, h.patch);
|
||||
enc = { ...enc, ...h.patch };
|
||||
console.log(` Cleric heal → ${tgt.name} (+${amt}, hp=${tgt.currentHp + amt})`);
|
||||
}
|
||||
}
|
||||
// random edit (notes/init unchanged)
|
||||
if (totalTurns % 11 === 0) {
|
||||
if (VERBOSE) console.log(` update ${actor.name} notes`);
|
||||
const res = await callStep('updateParticipant',
|
||||
{ participant: actor.name, fields: ['notes'] },
|
||||
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 3. conditions: toggle a queued condition off some participant each turn.
|
||||
if (condQueue.length > 0) {
|
||||
const cond = condQueue[0];
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try {
|
||||
const c = toggleCondition(enc, tgt.id, cond);
|
||||
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||
condQueue.shift();
|
||||
} catch (e) { console.log(` condition ${cond} err: ${e.message}`); condQueue.shift(); }
|
||||
}
|
||||
} else if (totalTurns % 6 === 0) {
|
||||
// second pass: toggle a random condition on random participant (add/remove).
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
|
||||
try {
|
||||
const c = toggleCondition(enc, tgt.id, cond);
|
||||
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// deathSave: PC at 0 HP
|
||||
if (actor.currentHp <= 0 && !actor.isNpc) {
|
||||
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
|
||||
const res = await callStep('deathSave',
|
||||
{ participant: actor.name, type: 'success', n: 1 },
|
||||
(e) => deathSave(e, actor.id, 'success', 1, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 4. toggleParticipantActive: randomly mark someone inactive, or reactivate.
|
||||
// toggleActive every 9 turns
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try {
|
||||
const r = toggleParticipantActive(enc, tgt.id);
|
||||
enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
|
||||
const res = await callStep('toggleParticipantActive',
|
||||
{ participant: tgt.name },
|
||||
(e) => toggleParticipantActive(e, tgt.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. deathSave: when a PC is at 0 HP on their turn, attempt a save.
|
||||
if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) {
|
||||
try {
|
||||
const ds = deathSave(enc, actor.id, 1);
|
||||
enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`);
|
||||
} catch (e) { /* ignore */ }
|
||||
// reinforcements every 20 turns (pause/add/resume)
|
||||
if (totalTurns % 20 === 0 && enc.isPaused === false) {
|
||||
if (VERBOSE) console.log(` reinforcements: pause`);
|
||||
let res = await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx));
|
||||
enc = res.enc;
|
||||
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
|
||||
if (VERBOSE) console.log(` add ${r.participant.name}`);
|
||||
res = await callStep('addParticipant',
|
||||
{ name: r.participant.name, reinforcement: true },
|
||||
(e) => addParticipant(e, r.participant, ctx));
|
||||
enc = res.enc;
|
||||
if (VERBOSE) console.log(` resume`);
|
||||
res = await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 6. removeParticipant: dead monsters hauled off (every ~5 turns).
|
||||
if (totalTurns % 5 === 0) {
|
||||
const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
|
||||
// remove dead monster every 13 turns
|
||||
if (totalTurns % 13 === 0) {
|
||||
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
|
||||
if (dead) {
|
||||
try {
|
||||
const r = removeParticipant(enc, dead.id);
|
||||
enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (VERBOSE) console.log(` remove ${dead.name}`);
|
||||
const res = await callStep('removeParticipant',
|
||||
{ participant: dead.name, dead: true },
|
||||
(e) => removeParticipant(e, dead.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. addParticipant (reinforcements): every 10 turns a new monster joins.
|
||||
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
|
||||
const spec = pick([
|
||||
{ name: `Reinforce${reinforcementsAdded + 1}`, maxHp: 120, initMod: 1 },
|
||||
{ name: `Summon${reinforcementsAdded + 1}`, maxHp: 80, initMod: 4 },
|
||||
]);
|
||||
try {
|
||||
const built = buildMonsterParticipant(spec).participant;
|
||||
const r = addParticipant(enc, built);
|
||||
enc = await patch(encounterPath, enc, r, `add ${spec.name}`);
|
||||
reinforcementsAdded++;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 8. updateParticipant: every 7 turns, edit a field on someone (e.g. temp AC).
|
||||
if (totalTurns % 7 === 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try {
|
||||
const r = updateParticipant(enc, tgt.id, { notes: `edited@turn${totalTurns}` });
|
||||
enc = await patch(encounterPath, enc, r, `edit ${tgt.name} notes`);
|
||||
} catch (e) { /* ignore */ }
|
||||
// drag same-init every 17 turns
|
||||
if (totalTurns % 17 === 0) {
|
||||
const order = enc.participants || [];
|
||||
if (order.length >= 2) {
|
||||
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
|
||||
if (b) {
|
||||
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
|
||||
const res = await callStep('reorderParticipants',
|
||||
{ dragged: b.name, target: a.name },
|
||||
(e) => reorderParticipants(e, b.id, a.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. togglePause: every 12 turns, pause (resumes next iteration via above).
|
||||
if (totalTurns % 12 === 0 && !lastPaused) {
|
||||
enc = await patch(encounterPath, enc, togglePause(enc), 'pause');
|
||||
lastPaused = true;
|
||||
}
|
||||
|
||||
// 10. reorderParticipants: every 8 turns, drag one past another (DM reorder).
|
||||
// Pick two ADJACENT UPCOMING actors (both strictly after current pointer)
|
||||
// and swap them. Avoids crossing current pointer — crossing it creates
|
||||
// ambiguous "who acted this round" semantics (skip/double). Swapping two
|
||||
// upcoming actors is always safe and still exercises reorder.
|
||||
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||
const curIdx = enc.turnOrderIds.indexOf(enc.currentTurnParticipantId);
|
||||
// upcoming = everyone after current in turn order (rest of this round)
|
||||
const upcomingIds = enc.turnOrderIds.slice(curIdx + 1)
|
||||
.filter(id => { const p = enc.participants.find(x => x.id === id); return p && p.currentHp > 0 && p.isActive !== false; });
|
||||
// swap first adjacent upcoming pair (drag index1 before index0)
|
||||
if (upcomingIds.length >= 2) {
|
||||
const target = enc.participants.find(p => p.id === upcomingIds[0]);
|
||||
const dragged = enc.participants.find(p => p.id === upcomingIds[1]);
|
||||
try {
|
||||
const r = reorderParticipants(enc, dragged.id, target.id);
|
||||
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
|
||||
lastReorder = totalTurns;
|
||||
} catch (e) { /* swap not allowed — skip this round */ }
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(DELAY);
|
||||
guard++;
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
}
|
||||
|
||||
if (DELAY > 0) await sleep(DELAY);
|
||||
|
||||
// advance turn (unless combat auto-ended)
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
const alive = enc.participants.filter(p => p.currentHp > 0).length;
|
||||
// revive dead: heal to full + reactivate. Sustains combat for 100 rounds
|
||||
// and exercises toggleActive reactivate + heal-from-zero path.
|
||||
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
try {
|
||||
if (VERBOSE) console.log(` -> nextTurn`);
|
||||
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
|
||||
enc = advRes.enc;
|
||||
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
|
||||
|
||||
// round wrap: revive dead so combat sustains to full ROUNDS count
|
||||
if (enc.isStarted && enc.round > lastRound) {
|
||||
if (enc.round > ROUNDS) break; // round cap reached, stop before printing/acting
|
||||
console.log(`--- round ${enc.round} ---`);
|
||||
lastRound = enc.round;
|
||||
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
if (d.isActive === false) {
|
||||
enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
||||
if (VERBOSE) console.log(` revive ${d.name}`);
|
||||
const res = await callStep('toggleParticipantActive',
|
||||
{ participant: d.name, revive: true },
|
||||
(e) => toggleParticipantActive(e, d.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
const h = applyHpChange(enc, d.id, 'heal', d.maxHp);
|
||||
enc = await patch(encounterPath, enc, h, `revive-heal ${d.name} →${d.maxHp}`);
|
||||
} catch (e) { console.log(` revive ${d.name} err: ${e.message}`); }
|
||||
if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`);
|
||||
const res = await callStep('applyHpChange',
|
||||
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
|
||||
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
}
|
||||
|
||||
console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`);
|
||||
console.log(`replay: ${totalTurns} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`);
|
||||
|
||||
// end
|
||||
// end encounter
|
||||
enc = await storage.getDoc(encounterPath);
|
||||
if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter');
|
||||
if (enc.isStarted) {
|
||||
const res = await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||
|
||||
jsonl.end();
|
||||
await new Promise(r => jsonl.close(r));
|
||||
console.log(`trace written: ${stepN} steps -> ${tracePath}`);
|
||||
console.log('');
|
||||
console.log('>>> ANALYZE: node scripts/analyze-turns.js "' + tracePath + '"');
|
||||
console.log('replay done');
|
||||
}
|
||||
|
||||
function firstActiveName(enc) {
|
||||
if (!enc.currentTurnParticipantId) return '(none)';
|
||||
const p = currentParticipant(enc);
|
||||
return p ? p.name : '(missing)';
|
||||
}
|
||||
|
||||
function currentParticipant(enc) {
|
||||
if (!enc.currentTurnParticipantId) return null;
|
||||
return (enc.participants || []).find(x => x.id === enc.currentTurnParticipantId) || null;
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('replay failed:', err); process.exit(1); });
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// scripts/replay-from-logs.js
|
||||
// Ingest canonical event JSON (downloaded app logs OR replay-combat --json-out)
|
||||
// and replay forward patches in order. Verifies each event's snapshot matches
|
||||
// the reconstructed encounter state — catches drift between logged intent and
|
||||
// actual mutation.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/replay-from-logs.js <events.json>
|
||||
// node scripts/replay-from-logs.js <events.json> --encounter <id> # filter
|
||||
// cat logs.json | node scripts/replay-from-logs.js # stdin
|
||||
//
|
||||
// Modes:
|
||||
// (default) in-memory replay + snapshot verification. No backend writes.
|
||||
// --write apply each patch to LIVE backend (same adapter as replay-combat).
|
||||
// Creates fresh encounter under new campaign. Useful for cloning
|
||||
// a logged combat into a clean DB.
|
||||
//
|
||||
// Output: turn sequence, round progression, drift report (any snapshot mismatch).
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const { normalizeEvent } = require('../shared/logEvent');
|
||||
|
||||
const WRITE_MODE = process.argv.includes('--write');
|
||||
const ENC_FILTER = (() => {
|
||||
const i = process.argv.indexOf('--encounter');
|
||||
return i >= 0 ? process.argv[i + 1] : null;
|
||||
})();
|
||||
|
||||
function readInput() {
|
||||
// positional .json arg, else stdin.
|
||||
const pos = process.argv[2];
|
||||
if (pos && !pos.startsWith('--')) return fs.readFileSync(pos, 'utf8');
|
||||
// stdin: require piped data (not interactive terminal). Avoid hang.
|
||||
if (process.stdin.isTTY) {
|
||||
console.error('Usage: node scripts/replay-from-logs.js <events.json> [--encounter <id>] [--write]');
|
||||
console.error(' cat events.json | node scripts/replay-from-logs.js');
|
||||
process.exit(2);
|
||||
}
|
||||
const data = fs.readFileSync(0, 'utf8');
|
||||
if (!data.trim()) {
|
||||
console.error('No input on stdin and no file arg.');
|
||||
process.exit(2);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Shallow merge patch into encounter (mirrors storage updateDoc semantics).
|
||||
function applyPatch(enc, patch) {
|
||||
if (!patch) return enc;
|
||||
const next = { ...enc };
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
next[k] = v;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Compare logged snapshot vs computed state. Returns list of field mismatches.
|
||||
function diffSnapshot(enc, snap) {
|
||||
if (!snap) return [];
|
||||
const drift = [];
|
||||
const curRound = enc.round ?? 0;
|
||||
const curTurn = enc.currentTurnParticipantId ?? null;
|
||||
const order = enc.turnOrderIds || [];
|
||||
const active = (enc.participants || []).filter(p => p.isActive).map(p => p.id);
|
||||
if (snap.round !== curRound) drift.push(`round: logged=${snap.round} actual=${curRound}`);
|
||||
if (snap.currentTurnParticipantId !== curTurn) drift.push(`currentTurn: logged=${snap.currentTurnParticipantId} actual=${curTurn}`);
|
||||
if (JSON.stringify(snap.turnOrderIds || []) !== JSON.stringify(order)) drift.push(`turnOrderIds: logged=${JSON.stringify(snap.turnOrderIds || [])} actual=${JSON.stringify(order)}`);
|
||||
if (JSON.stringify(snap.activeIds || []) !== JSON.stringify(active)) drift.push(`activeIds: logged=${JSON.stringify(snap.activeIds || [])} actual=${JSON.stringify(active)}`);
|
||||
return drift;
|
||||
}
|
||||
|
||||
function nameMap(enc) {
|
||||
const m = new Map();
|
||||
for (const p of (enc.participants || [])) if (p.id && p.name) m.set(p.id, p.name);
|
||||
return m;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const text = readInput().trim();
|
||||
const raw = JSON.parse(text);
|
||||
const arr = (Array.isArray(raw) ? raw : [raw]).map(normalizeEvent).filter(Boolean);
|
||||
const events = ENC_FILTER ? arr.filter(e => e.encounterId === ENC_FILTER) : arr;
|
||||
|
||||
console.log(`replay-from-logs: ${events.length} events${ENC_FILTER ? ` (encounter ${ENC_FILTER})` : ''}`);
|
||||
|
||||
let enc = null;
|
||||
let encName = null;
|
||||
let encPath = null;
|
||||
let round = 0;
|
||||
let turnCount = 0;
|
||||
let appliedCount = 0;
|
||||
const drift = [];
|
||||
const turnSequence = [];
|
||||
|
||||
for (const e of events) {
|
||||
// init on first event
|
||||
if (!enc) {
|
||||
enc = applyPatch({}, e.payload);
|
||||
encName = e.encounterName;
|
||||
encPath = e.encounterPath;
|
||||
} else {
|
||||
enc = applyPatch(enc, e.payload);
|
||||
}
|
||||
appliedCount++;
|
||||
|
||||
if (e.snapshot) {
|
||||
const d = diffSnapshot(enc, e.snapshot);
|
||||
if (d.length) drift.push({ event: e, fields: d });
|
||||
}
|
||||
|
||||
if (e.type === 'next_turn') {
|
||||
turnCount++;
|
||||
const names = nameMap(enc);
|
||||
const actor = e.snapshot?.currentTurnParticipantId;
|
||||
const actorName = names.get(actor) || actor || '?';
|
||||
const r = e.snapshot?.round ?? enc?.round ?? 0;
|
||||
if (r !== round) { round = r; }
|
||||
turnSequence.push(`R${r}: ${actorName}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`encounter: ${encName || '?'}`);
|
||||
console.log(`events applied: ${appliedCount} / ${events.length}`);
|
||||
console.log(`turns replayed: ${turnCount}`);
|
||||
console.log(`final round: ${round}`);
|
||||
console.log(`participants at end: ${(enc?.participants || []).length}`);
|
||||
|
||||
if (drift.length) {
|
||||
console.log(`\n--- SNAPSHOT DRIFT (${drift.length} events) ---`);
|
||||
for (const { event: e, fields } of drift.slice(0, 10)) {
|
||||
console.log(` ${e.type} (${e.id || e.ts}): ${fields.join('; ')}`);
|
||||
}
|
||||
if (drift.length > 10) console.log(` ... +${drift.length - 10} more`);
|
||||
} else {
|
||||
console.log('\nsnapshots: all match');
|
||||
}
|
||||
|
||||
console.log(`\n=== ${turnCount} turns, ${drift.length} drift ===`);
|
||||
console.log(drift.length === 0 ? 'CLEAN — replay matches logged intent' : 'DRIFT — logged snapshots diverge from applied patches');
|
||||
|
||||
process.exit(drift.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('replay-from-logs failed:', err); process.exit(1); });
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run-tests.sh — run all 3 test suites with hard timeout.
|
||||
# Design spec: tests never take >60s. Hang = failure, not silent.
|
||||
# Each suite gets 60s. Total cap = 180s. Exits non-zero on timeout OR failure.
|
||||
set -euo pipefail
|
||||
|
||||
TIMEOUT_BIN=gtimeout
|
||||
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
|
||||
|
||||
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
|
||||
echo "ERROR: need 'timeout' (coreutils) or 'gtimeout' (brew coreutils)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
run_suite () {
|
||||
local label="$1"; shift
|
||||
local cmd="$1"; shift
|
||||
local secs="${1:-60}"
|
||||
echo "=== $label (${secs}s cap) ==="
|
||||
if $TIMEOUT_BIN --signal=KILL "$secs" bash -c "$cmd"; then
|
||||
echo "=== $label: PASS ==="
|
||||
else
|
||||
local rc=$?
|
||||
if [ "$rc" -eq 137 ] || [ "$rc" -eq 124 ]; then
|
||||
echo "=== $label: FAIL — timeout (${secs}s exceeded, killed) ===" >&2
|
||||
else
|
||||
echo "=== $label: FAIL (exit $rc) ===" >&2
|
||||
fi
|
||||
exit "$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# Pre-flight: refuse to run if any test is skipped/disabled.
|
||||
# Skipped tests rot silently. Catch them before they hide coverage gaps.
|
||||
check_for_skips () {
|
||||
echo "=== skip-guard ==="
|
||||
local hits
|
||||
hits=$(grep -rnE '\.(skip|todo)\(|xdescribe\(|xit\(' src/tests shared/tests server/tests 2>/dev/null || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "FAIL: skipped/disabled tests found:" >&2
|
||||
echo "$hits" >&2
|
||||
echo "" >&2
|
||||
echo "Fix or delete. Do not skip." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== skip-guard: PASS ==="
|
||||
}
|
||||
check_for_skips
|
||||
|
||||
run_suite "app" "CI=true npx react-scripts test --watchAll=false" 60
|
||||
run_suite "shared" "npm test --workspace shared" 30
|
||||
run_suite "server" "npm test --workspace server" 30
|
||||
|
||||
echo "=== ALL SUITES GREEN ==="
|
||||
+70
-4
@@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS docs (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
|
||||
-- Hot path: logs page subscribes to latest logs ordered by JSON ts.
|
||||
-- Without this, every log write makes UI re-fetch + sort all logs.
|
||||
CREATE INDEX IF NOT EXISTS idx_docs_parent_ts ON docs(parent, CAST(json_extract(data, '$.ts') AS NUMERIC));
|
||||
CREATE INDEX IF NOT EXISTS idx_docs_parent_encounter_ts ON docs(parent, json_extract(data, '$.encounterPath'), CAST(json_extract(data, '$.ts') AS NUMERIC));
|
||||
`;
|
||||
|
||||
function openDb(dbPath) {
|
||||
@@ -52,7 +56,6 @@ function makeStore(db, broadcast) {
|
||||
ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts
|
||||
`);
|
||||
const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?');
|
||||
const stmtColl = db.prepare('SELECT path, data FROM docs WHERE parent = ? ORDER BY path ASC');
|
||||
|
||||
function getDoc(p) {
|
||||
const row = stmtGet.get(p);
|
||||
@@ -79,8 +82,54 @@ function makeStore(db, broadcast) {
|
||||
if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true });
|
||||
}
|
||||
|
||||
function getCollection(collPath) {
|
||||
return stmtColl.all(collPath).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) }));
|
||||
function getCollection(collPath, { where, orderBy, dir = 'asc', limit, offset } = {}) {
|
||||
let sql = 'SELECT path, data FROM docs WHERE parent = ?';
|
||||
const params = [collPath];
|
||||
if (where) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
|
||||
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
|
||||
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
|
||||
params.push(where.value);
|
||||
}
|
||||
if (orderBy) {
|
||||
// whitelist field name to avoid injection (alphanumeric + underscore only)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(orderBy)) throw new Error(`bad orderBy field: ${orderBy}`);
|
||||
sql += ` ORDER BY CAST(json_extract(data, '$.${orderBy}') AS NUMERIC) ${dir === 'desc' ? 'DESC' : 'ASC'}`;
|
||||
} else {
|
||||
sql += ' ORDER BY path ASC';
|
||||
}
|
||||
if (limit && Number.isInteger(+limit) && +limit > 0) {
|
||||
sql += ' LIMIT ?';
|
||||
params.push(+limit);
|
||||
if (offset && Number.isInteger(+offset) && +offset > 0) {
|
||||
sql += ' OFFSET ?';
|
||||
params.push(+offset);
|
||||
}
|
||||
}
|
||||
return db.prepare(sql).all(...params).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) }));
|
||||
}
|
||||
|
||||
function countCollection(collPath) {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE parent = ?').get(collPath).n;
|
||||
}
|
||||
|
||||
// Bulk delete whole collection or by where-filter. No fetch. SQL knows paths.
|
||||
// DEV ONLY — guarded at HTTP layer; db fn itself unguarded (server-internal trust).
|
||||
function deleteCollection(collPath, { where } = {}) {
|
||||
let sql = 'DELETE FROM docs WHERE parent = ?';
|
||||
const params = [collPath];
|
||||
const changed = [];
|
||||
if (where) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
|
||||
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
|
||||
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
|
||||
params.push(where.value);
|
||||
}
|
||||
// collect paths+parents for broadcast before delete
|
||||
const rows = db.prepare('SELECT path, parent FROM docs WHERE parent = ?' + (where ? ` AND json_extract(data, '$.${where.field}') = ?` : '')).all(...params);
|
||||
const info = db.prepare(sql).run(...params);
|
||||
if (broadcast) rows.forEach(r => broadcast({ path: r.path, parent: r.parent, deleted: true }));
|
||||
return info.changes;
|
||||
}
|
||||
|
||||
function batchWrite(ops) {
|
||||
@@ -104,7 +153,24 @@ function makeStore(db, broadcast) {
|
||||
if (broadcast) changed.forEach(c => broadcast(c));
|
||||
}
|
||||
|
||||
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, batchWrite };
|
||||
// Transactional undo: apply encounter updates + flip log `undone` in one tx.
|
||||
// No stale clobber, atomic. Returns { undone: true } or throws.
|
||||
function transactionalUndo({ logPath, encounterPath, updates, undoneFlag }) {
|
||||
const run = db.transaction(() => {
|
||||
const merged = updateDoc(encounterPath, updates);
|
||||
const log = getDoc(logPath) || {};
|
||||
setDoc(logPath, { ...log, undone: undoneFlag });
|
||||
return { encounter: merged, undone: undoneFlag };
|
||||
});
|
||||
const result = run();
|
||||
if (broadcast) {
|
||||
broadcast({ path: encounterPath, parent: parentOf(encounterPath) });
|
||||
broadcast({ path: logPath, parent: parentOf(logPath) });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, countCollection, deleteCollection, batchWrite, transactionalUndo };
|
||||
}
|
||||
|
||||
module.exports = { openDb, parentOf, makeStore };
|
||||
|
||||
+55
-2
@@ -11,7 +11,7 @@ const crypto = require('crypto');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { openDb, makeStore } = require('./db');
|
||||
|
||||
function createServer({ dbPath, port, corsOrigin } = {}) {
|
||||
function createServer({ dbPath, port, corsOrigin, allowDevEndpoints = false } = {}) {
|
||||
const db = openDb(dbPath || './data/tracker.sqlite');
|
||||
const app = express();
|
||||
app.use(cors({ origin: corsOrigin || '*' }));
|
||||
@@ -64,9 +64,21 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
|
||||
|
||||
// GET /api/collection?path=campaigns/abc/encounters
|
||||
app.get('/api/collection', (req, res) => {
|
||||
const { path: p, whereField, whereOp, whereValue, orderBy, dir, limit, offset } = req.query;
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
const opts = {};
|
||||
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
|
||||
if (orderBy) opts.orderBy = orderBy;
|
||||
if (dir) opts.dir = dir;
|
||||
if (limit) opts.limit = limit;
|
||||
if (offset) opts.offset = offset;
|
||||
res.json(store.getCollection(p, opts));
|
||||
});
|
||||
|
||||
app.get('/api/collection/count', (req, res) => {
|
||||
const { path: p } = req.query;
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
res.json(store.getCollection(p));
|
||||
res.json({ count: store.countCollection(p) });
|
||||
});
|
||||
|
||||
// PUT /api/doc body: { path, data } (replace)
|
||||
@@ -91,6 +103,21 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/collection?path=...&whereField=...&whereValue=...
|
||||
// Bulk delete whole collection or filtered. No fetch. SQL DELETE.
|
||||
// DEV ONLY: requires ALLOW_DEV_ENDPOINTS=1 env on server.
|
||||
app.delete('/api/collection', (req, res) => {
|
||||
if (!allowDevEndpoints && process.env.ALLOW_DEV_ENDPOINTS !== '1') {
|
||||
return res.status(403).json({ error: 'bulk delete disabled (dev only)' });
|
||||
}
|
||||
const { path: p, whereField, whereOp, whereValue } = req.query;
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
const opts = {};
|
||||
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
|
||||
const deleted = store.deleteCollection(p, opts);
|
||||
res.json({ ok: true, deleted });
|
||||
});
|
||||
|
||||
// POST /api/collection body: { path, data } (addDoc: auto-id under collection)
|
||||
app.post('/api/collection', (req, res) => {
|
||||
const { path: collPath, data } = req.body || {};
|
||||
@@ -108,6 +135,32 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/undo body: { logPath, undo } where undo={encounterPath, updates, redo}
|
||||
// Transactional: apply updates + flip undone in one SQLite tx.
|
||||
// If `redo` true, applies redo patch instead + flips undone:false.
|
||||
app.post('/api/undo', (req, res) => {
|
||||
const { logPath, undo } = req.body || {};
|
||||
if (!logPath || !undo || !undo.encounterPath) {
|
||||
return res.status(400).json({ error: 'logPath + undo.encounterPath required' });
|
||||
}
|
||||
if (!store.transactionalUndo) {
|
||||
return res.status(501).json({ error: 'transactionalUndo not supported by store' });
|
||||
}
|
||||
try {
|
||||
const isRedo = !!req.body.redo;
|
||||
const updates = isRedo ? (undo.redo || undo.updates) : undo.updates;
|
||||
const result = store.transactionalUndo({
|
||||
logPath,
|
||||
encounterPath: undo.encounterPath,
|
||||
updates,
|
||||
undoneFlag: !isRedo,
|
||||
});
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- WebSocket: subscribe by path ---
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"dev": "node --watch index.js",
|
||||
"start": "node index.js",
|
||||
"test": "jest --forceExit"
|
||||
"test": "../scripts/cap.sh 30 jest --forceExit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ttrpg/shared": "*",
|
||||
|
||||
@@ -16,9 +16,9 @@ const { runStorageContract } = require('../../src/storage/contract');
|
||||
|
||||
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
|
||||
// Disposing the storage closes the backend so each test is fully isolated.
|
||||
async function makeStorage() {
|
||||
async function makeStorage({ allowDevEndpoints = false } = {}) {
|
||||
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
||||
const handle = createServer({ dbPath, port: 0 });
|
||||
const handle = createServer({ dbPath, port: 0, allowDevEndpoints });
|
||||
await new Promise((resolve, reject) => {
|
||||
handle.server.on('error', reject);
|
||||
handle.server.listen(0, resolve);
|
||||
@@ -31,4 +31,85 @@ async function makeStorage() {
|
||||
return storage;
|
||||
}
|
||||
|
||||
runStorageContract('server (live backend)', makeStorage);
|
||||
runStorageContract('server (live backend)', () => makeStorage({ allowDevEndpoints: true }));
|
||||
|
||||
describe('server-side query constraints', () => {
|
||||
let storage;
|
||||
beforeEach(async () => {
|
||||
storage = await makeStorage();
|
||||
});
|
||||
afterEach((done) => storage.dispose(done));
|
||||
|
||||
test('GET /api/collection honors orderBy + limit (server-side, not client)', async () => {
|
||||
// 5 docs with distinct ts
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
|
||||
}
|
||||
|
||||
const res = await storage._api('GET', '/api/collection',
|
||||
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2' });
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
expect(res.length).toBe(2);
|
||||
// desc: highest ts first
|
||||
expect(res[0].data ? res[0].data.n : res[0].n).toBe(5);
|
||||
expect(res[1].data ? res[1].data.n : res[1].n).toBe(4);
|
||||
});
|
||||
|
||||
test('GET /api/collection honors offset for pagination', async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
|
||||
}
|
||||
// page 2 of size 2, desc by ts: should be n=3,2
|
||||
const res = await storage._api('GET', '/api/collection',
|
||||
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2', offset: '2' });
|
||||
expect(res.length).toBe(2);
|
||||
expect(res[0].data ? res[0].data.n : res[0].n).toBe(3);
|
||||
expect(res[1].data ? res[1].data.n : res[1].n).toBe(2);
|
||||
});
|
||||
|
||||
test('GET /api/collection/count returns total', async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
|
||||
}
|
||||
const res = await storage._api('GET', '/api/collection/count', { path: 'logs' });
|
||||
expect(res).toEqual({ count: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/collection (bulk delete)', () => {
|
||||
let storage;
|
||||
beforeEach(async () => {
|
||||
storage = await makeStorage({ allowDevEndpoints: true });
|
||||
});
|
||||
afterEach((done) => storage.dispose(done));
|
||||
|
||||
test('deletes all docs in collection, no fetch', async () => {
|
||||
for (let i = 0; i < 3; i++) await storage.addDoc('logs', { type: 'x', n: i });
|
||||
const res = await storage._api('DELETE', '/api/collection', { path: 'logs' });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.deleted).toBe(3);
|
||||
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
|
||||
expect(after.count).toBe(0);
|
||||
});
|
||||
|
||||
test('honors whereField filter', async () => {
|
||||
await storage.addDoc('logs', { type: 'keep', n: 1 });
|
||||
await storage.addDoc('logs', { type: 'drop', n: 2 });
|
||||
await storage.addDoc('logs', { type: 'drop', n: 3 });
|
||||
const res = await storage._api('DELETE', '/api/collection',
|
||||
{ path: 'logs', whereField: 'type', whereValue: 'drop' });
|
||||
expect(res.deleted).toBe(2);
|
||||
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
|
||||
expect(after.count).toBe(1);
|
||||
});
|
||||
|
||||
test('403 when ALLOW_DEV_ENDPOINTS not set', async () => {
|
||||
const gated = await makeStorage({ allowDevEndpoints: false });
|
||||
let errMsg = null;
|
||||
try {
|
||||
await gated._api('DELETE', '/api/collection', { path: 'logs' });
|
||||
} catch (e) { errMsg = e.message; }
|
||||
expect(errMsg).toMatch(/403/);
|
||||
await new Promise((resolve) => gated.dispose(resolve));
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
// @ttrpg/shared — barrel export.
|
||||
module.exports = require('./turn.js');
|
||||
const turn = require('./turn.js');
|
||||
module.exports = { ...turn, logEvent: require('./logEvent.js') };
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// shared/logEvent.js — canonical event shape for UI/download/replay/analyze.
|
||||
// One source of truth. All four consumers import this.
|
||||
//
|
||||
// Canonical event:
|
||||
// {
|
||||
// id, ts, type, message,
|
||||
// encounterId, encounterName, encounterPath,
|
||||
// payload, // forward patch (null for no-op)
|
||||
// undo_payload, // { encounterPath, updates, redo } or null
|
||||
// undone, // bool
|
||||
// snapshot // { round, currentTurnParticipantId, turnOrderIds, activeIds } or null
|
||||
// }
|
||||
//
|
||||
// Old logs (pre-refactor): { timestamp, message, encounterName, undo }.
|
||||
// normalizeEvent fills defaults + lifts legacy undo into undo_payload.
|
||||
|
||||
const DEFAULTS = {
|
||||
ts: 0,
|
||||
type: 'unknown',
|
||||
message: '',
|
||||
encounterId: null,
|
||||
encounterName: null,
|
||||
encounterPath: null,
|
||||
payload: null,
|
||||
undo_payload: null,
|
||||
undone: false,
|
||||
snapshot: null,
|
||||
};
|
||||
|
||||
// Canonical lean event shape:
|
||||
// {
|
||||
// id, ts, type, message,
|
||||
// encounterId, encounterName, encounterPath,
|
||||
// participantId, participantName, // nullable (pause/nextTurn have none)
|
||||
// delta, // type-specific scalars (nullable)
|
||||
// undo, // id-based inverse (nullable)
|
||||
// undone, // bool
|
||||
// snapshot // {round, currentTurnParticipantId, turnOrderIds, activeIds}
|
||||
// }
|
||||
// Legacy logs (pre-refactor, no type): display message only. Not undoable.
|
||||
// Old bloated logs (payload/undo_payload): normalized to lean — payload dropped,
|
||||
// undo_payload lifted to undo. Both display fine; undo expands via expandUndo.
|
||||
function normalizeEvent(raw) {
|
||||
if (!raw) return null;
|
||||
if (!raw.type || raw.type === 'unknown') return null;
|
||||
const id = raw.id || (raw.path ? raw.path.split('/').pop() : null);
|
||||
return {
|
||||
id,
|
||||
ts: raw.ts || raw.timestamp || 0,
|
||||
type: raw.type,
|
||||
message: raw.message || '',
|
||||
encounterId: raw.encounterId || null,
|
||||
encounterName: raw.encounterName || null,
|
||||
encounterPath: raw.encounterPath || null,
|
||||
participantId: raw.participantId || null,
|
||||
participantName: raw.participantName || null,
|
||||
delta: raw.delta || null,
|
||||
undo: raw.undo || raw.undo_payload || null,
|
||||
undone: !!raw.undone,
|
||||
snapshot: raw.snapshot || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Serialize array of raw log entries (DB rows) → canonical JSON string.
|
||||
// Legacy logs (no type) dropped by normalizeEvent. Sorted ascending by ts.
|
||||
function serializeEvents(rawLogs) {
|
||||
return JSON.stringify(
|
||||
(rawLogs || [])
|
||||
.map(normalizeEvent)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.ts - b.ts),
|
||||
null, 2
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { normalizeEvent, serializeEvents, DEFAULTS };
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"description": "Pure logic shared by client + server + tests. No I/O.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
"test": "../scripts/cap.sh 30 jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Test Rewrite Spec — async turn.js writes own logs
|
||||
|
||||
## New turn.js API
|
||||
|
||||
ALL mutating funcs now `async`, take `ctx` last param, write encounter + log internally, return `newEnc`.
|
||||
|
||||
```js
|
||||
// OLD
|
||||
const r = nextTurn(enc);
|
||||
enc = { ...enc, ...r.patch };
|
||||
// caller had to write log separately
|
||||
|
||||
// NEW
|
||||
enc = await nextTurn(enc, ctx);
|
||||
// encounter + log already written inside func
|
||||
```
|
||||
|
||||
### Func signatures (all async unless noted)
|
||||
- `startEncounter(enc, ctx)` → newEnc
|
||||
- `nextTurn(enc, ctx)` → newEnc
|
||||
- `togglePause(enc, ctx)` → newEnc
|
||||
- `addParticipant(enc, participant, ctx)` → newEnc
|
||||
- `addParticipants(enc, newParticipants[], ctx)` → newEnc
|
||||
- `updateParticipant(enc, id, updatedData, ctx)` → newEnc
|
||||
- `removeParticipant(enc, id, ctx)` → newEnc
|
||||
- `toggleParticipantActive(enc, id, ctx)` → newEnc
|
||||
- `applyHpChange(enc, id, changeType, amount, ctx)` → newEnc (no-op = returns enc unchanged, no write)
|
||||
- `deathSave(enc, id, type, n, ctx)` → `{ enc, status, isDying }` (object now!)
|
||||
- `toggleCondition(enc, id, conditionId, ctx)` → newEnc
|
||||
- `reorderParticipants(enc, draggedId, targetId, ctx)` → newEnc (no-op = returns enc, no write)
|
||||
- `endEncounter(enc, ctx)` → newEnc
|
||||
|
||||
### Pure helpers (sync, unchanged)
|
||||
- `makeParticipant`, `buildCharacterParticipant`, `buildMonsterParticipant`
|
||||
- `generateId`, `rollD20`, `formatInitMod`
|
||||
- `sortParticipantsByInitiative`, `syncTurnOrder`, `computeTurnOrderAfterRemoval`
|
||||
- `snapshotOf`, `buildEntry`
|
||||
|
||||
### Display lifecycle (async, no combat log)
|
||||
- `activateDisplay({campaignId, encounterId}, ctx)` → void
|
||||
- `clearDisplay(ctx)` → void
|
||||
- `toggleHidePlayerHp(currentValue, ctx)` → void
|
||||
|
||||
## Test helper: shared/tests/_helpers.js
|
||||
|
||||
```js
|
||||
const { createMockStorage, mockCtx, readyEnc } = require('./_helpers');
|
||||
const { storage, ctx } = mockCtx(); // ctx = {storage, encPath:'encounters/e1', logPath:'logs', displayPath:'activeDisplay/status'}
|
||||
const enc = readyEnc(); // {id:'e1', name:'TestEnc', round:0, ... 2 participants p1(Bob,init10) p2(Mob,init5)}
|
||||
```
|
||||
|
||||
### Mock storage methods
|
||||
- `storage.calls` — array of `{fn, path, data}` ordered
|
||||
- `storage.logs()` — array of log entries written (addDoc data)
|
||||
- `storage.updatesFor(prefix)` — updateDoc calls matching path prefix
|
||||
- `storage.docs` — Map path→data
|
||||
|
||||
## Transform patterns
|
||||
|
||||
### Pattern 1: state assertion
|
||||
```js
|
||||
// OLD
|
||||
const r = startEncounter(enc);
|
||||
expect(r.patch.round).toBe(1);
|
||||
|
||||
// NEW
|
||||
const newEnc = await startEncounter(enc, ctx);
|
||||
expect(newEnc.round).toBe(1);
|
||||
```
|
||||
|
||||
### Pattern 2: log assertion
|
||||
```js
|
||||
// OLD
|
||||
const r = startEncounter(enc);
|
||||
expect(r.log.type).toBe('start_encounter');
|
||||
|
||||
// NEW
|
||||
const newEnc = await startEncounter(enc, ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expect(storage.logs()[0].type).toBe('start_encounter');
|
||||
```
|
||||
|
||||
### Pattern 3: chained calls (sequence)
|
||||
```js
|
||||
// OLD
|
||||
let enc = readyEnc();
|
||||
enc = { ...enc, ...startEncounter(enc).patch };
|
||||
enc = { ...enc, ...nextTurn(enc).patch };
|
||||
|
||||
// NEW
|
||||
let enc = readyEnc();
|
||||
enc = await startEncounter(enc, ctx);
|
||||
enc = await nextTurn(enc, ctx);
|
||||
```
|
||||
|
||||
### Pattern 4: no-op
|
||||
```js
|
||||
// OLD (returned {patch:null, log:null})
|
||||
const r = applyHpChange(enc, 'p1', 'damage', 0);
|
||||
expect(r.patch).toBeNull();
|
||||
|
||||
// NEW (returns enc unchanged, no write)
|
||||
const newEnc = await applyHpChange(enc, 'p1', 'damage', 0, ctx);
|
||||
expect(newEnc).toBe(enc); // same ref = no write
|
||||
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
|
||||
```
|
||||
|
||||
### Pattern 5: deathSave (returns object now)
|
||||
```js
|
||||
// OLD
|
||||
const r = deathSave(enc, 'p1', 'success', 1);
|
||||
expect(r.status).toBe('pending');
|
||||
|
||||
// NEW
|
||||
const { enc: newEnc, status, isDying } = await deathSave(enc, 'p1', 'success', 1, ctx);
|
||||
expect(status).toBe('pending');
|
||||
```
|
||||
|
||||
## Static guards
|
||||
|
||||
- `static.no-sort.test.js` — KEEP. Still valid (scans turn.js for stray `.sort(`).
|
||||
- `static.no-unlogged.test.js` — DELETE. Old `{patch, log}` shape gone. Every func writes log internally via `commit()`. No way to "forget".
|
||||
- `static.eslint.test.js` — KEEP.
|
||||
|
||||
## Constraints
|
||||
|
||||
- KEEP test names (describe/it blocks) — same coverage intent.
|
||||
- KEEP assertions — same values checked, just via newEnc or storage.logs().
|
||||
- Fresh `mockCtx()` per test (isolation).
|
||||
- For seeded-RNG combat tests: keep the LCG seed + rand helpers, just swap call pattern.
|
||||
- `await` every mutating func call.
|
||||
@@ -0,0 +1,149 @@
|
||||
// Test helper: in-memory mock storage. Records all writes (addDoc/updateDoc).
|
||||
// Stores docs so getDoc/getCollection return real state. undo() actually
|
||||
// applies patches + flips log `undone` flag (mirrors firebase/server contract).
|
||||
function createMockStorage() {
|
||||
const docs = new Map(); // path -> data
|
||||
const calls = []; // ordered {fn, path, data}
|
||||
|
||||
const applyPatch = (path, patch) => {
|
||||
if (!docs.has(path)) docs.set(path, {});
|
||||
const cur = docs.get(path);
|
||||
// Firestore shallow-merge: arrays replaced, not merged. Patch arrays are
|
||||
// the FULL new value (participants, turnOrderIds, conditions).
|
||||
docs.set(path, { ...cur, ...patch });
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
docs,
|
||||
async getDoc(path) { return docs.has(path) ? { ...docs.get(path) } : null; },
|
||||
async setDoc(path, data) {
|
||||
calls.push({ fn: 'setDoc', path, data });
|
||||
docs.set(path, { ...data });
|
||||
},
|
||||
async addDoc(path, data) {
|
||||
calls.push({ fn: 'addDoc', path, data });
|
||||
// Persist log entry so getCollection + undo can read it back.
|
||||
const id = data.id || `log-${calls.length}`;
|
||||
const stored = { ...data, id, _path: `${path}/${id}` };
|
||||
docs.set(`${path}/${id}`, stored);
|
||||
return id;
|
||||
},
|
||||
async updateDoc(path, patch) {
|
||||
calls.push({ fn: 'updateDoc', path, data: patch });
|
||||
applyPatch(path, patch);
|
||||
},
|
||||
async deleteDoc(path) {
|
||||
calls.push({ fn: 'deleteDoc', path });
|
||||
docs.delete(path);
|
||||
},
|
||||
async getCollection(path, queryConstraints = []) {
|
||||
// Return docs whose _path starts with `${path}/` (Firestore collection).
|
||||
const out = [];
|
||||
for (const [p, d] of docs.entries()) {
|
||||
if (p.startsWith(`${path}/`)) out.push({ ...d });
|
||||
}
|
||||
// Best-effort query support: where/orderBy/limit.
|
||||
let filtered = out;
|
||||
for (const qc of queryConstraints) {
|
||||
if (!filtered.length) break;
|
||||
const op = qc[0];
|
||||
if (op === 'where') {
|
||||
const [, field, , value] = qc;
|
||||
filtered = filtered.filter(d => d[field] === value);
|
||||
} else if (op === 'orderBy') {
|
||||
const [, field, dir = 'asc'] = qc;
|
||||
filtered.sort((a, b) => {
|
||||
const av = a[field], bv = b[field];
|
||||
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||||
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
} else if (op === 'limit') {
|
||||
filtered = filtered.slice(0, qc[1]);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
},
|
||||
async batchWrite(ops) {
|
||||
for (const op of ops) {
|
||||
if (op.type === 'delete') await this.deleteDoc(op.path);
|
||||
else await this.updateDoc(op.path, op.data);
|
||||
}
|
||||
},
|
||||
// Transactional undo — mirrors firebase.js / server.js contract:
|
||||
// redo=false: apply undo.updates to encounterPath, flip log undone=true
|
||||
// redo=true: apply undo.redo to encounterPath, flip log undone=false
|
||||
async undo({ logPath, undo, redo = false }) {
|
||||
calls.push({ fn: 'undo', path: logPath, data: { undo, redo } });
|
||||
const patch = redo ? (undo.redo || undo.updates) : undo.updates;
|
||||
applyPatch(undo.encounterPath, patch);
|
||||
if (docs.has(logPath)) {
|
||||
const log = docs.get(logPath);
|
||||
docs.set(logPath, { ...log, undone: !redo });
|
||||
}
|
||||
},
|
||||
// filters
|
||||
updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); },
|
||||
logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
|
||||
getLogs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
|
||||
};
|
||||
}
|
||||
|
||||
// Undo/redo harness: apply op via shared func (writes enc doc + log) then
|
||||
// exercise the REAL undo mechanism (storage.undo applies patch + flips flag).
|
||||
// Reads doc back so assertions reflect actual persisted state.
|
||||
// applyThenUndo(ctx, encAfterOp) -> enc after undo applied
|
||||
// applyThenRedo(ctx, encAfterUndo, lastLogId) -> enc after redo applied
|
||||
async function undoLast(ctx, encAfterOp) {
|
||||
const { storage, encPath, logPath } = ctx;
|
||||
const last = storage.logs()[storage.logs().length - 1];
|
||||
const { expandUndo } = require('./..');
|
||||
const expanded = expandUndo(last, encAfterOp);
|
||||
if (!expanded) throw new Error('expandUndo returned null');
|
||||
const logEntryPath = `${logPath}/${last.id}`;
|
||||
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: false });
|
||||
return await storage.getDoc(encPath);
|
||||
}
|
||||
async function redoLast(ctx, encAfterUndo, last) {
|
||||
const { storage, encPath, logPath } = ctx;
|
||||
const { expandUndo } = require('./..');
|
||||
const expanded = expandUndo(last, encAfterUndo);
|
||||
if (!expanded) throw new Error('expandUndo returned null (redo)');
|
||||
const logEntryPath = `${logPath}/${last.id}`;
|
||||
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: true });
|
||||
return await storage.getDoc(encPath);
|
||||
}
|
||||
|
||||
// Standard ctx for tests. encPath + logPath + displayPath preset.
|
||||
// Returns { storage, ctx } so tests destructure both in one call:
|
||||
// const { storage, ctx } = mockCtx();
|
||||
function mockCtx(storage) {
|
||||
storage = storage || createMockStorage();
|
||||
const ctx = {
|
||||
storage,
|
||||
encPath: 'encounters/e1',
|
||||
logPath: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
return { storage, ctx };
|
||||
}
|
||||
|
||||
// Standard 2-participant ready encounter.
|
||||
function readyEnc(opts = {}) {
|
||||
return {
|
||||
id: 'e1',
|
||||
name: opts.name || 'TestEnc',
|
||||
round: 0,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
participants: opts.participants || [
|
||||
{ id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
|
||||
{ id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createMockStorage, mockCtx, readyEnc, undoLast, redoLast };
|
||||
@@ -0,0 +1,81 @@
|
||||
// Test logEvent normalizer — canonical shape for UI/download/replay/analyze.
|
||||
// New design: legacy logs (no type) DROPPED. Not in pipeline. Kept only for
|
||||
// human scroll in LogsView (raw). download/copy/replay/analyze = typed only.
|
||||
const { normalizeEvent, serializeEvents } = require('../logEvent.js');
|
||||
|
||||
describe('logEvent normalizer', () => {
|
||||
test('new schema passes through', () => {
|
||||
const raw = {
|
||||
id: 'l1', ts: 1000, type: 'next_turn', message: 'Bob turn',
|
||||
encounterId: 'e1', encounterName: 'Enc', encounterPath: 'encounters/e1',
|
||||
participantId: 'p1', participantName: 'Bob',
|
||||
delta: { wrapped: true, prevRound: 1 },
|
||||
undo: { currentTurnParticipantId: 'p0', round: 1, turnOrderIds: ['p0','p1'] },
|
||||
undone: false, snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
|
||||
};
|
||||
const e = normalizeEvent(raw);
|
||||
expect(e.id).toBe('l1');
|
||||
expect(e.type).toBe('next_turn');
|
||||
expect(e.snapshot.round).toBe(2);
|
||||
expect(e.delta.wrapped).toBe(true);
|
||||
expect(e.undo.round).toBe(1);
|
||||
expect(e.participantName).toBe('Bob');
|
||||
});
|
||||
|
||||
test('old bloated schema (payload/undo_payload) lifts undo, drops payload', () => {
|
||||
const raw = {
|
||||
id: 'l1b', ts: 1100, type: 'damage', message: 'dmg',
|
||||
encounterPath: 'encounters/e1',
|
||||
payload: { participants: [{ id: 'p1', currentHp: 30 }] },
|
||||
undo_payload: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||
snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
|
||||
};
|
||||
const e = normalizeEvent(raw);
|
||||
expect(e.type).toBe('damage');
|
||||
expect(e.undo).toBeTruthy();
|
||||
expect(e.delta).toBeNull();
|
||||
});
|
||||
|
||||
test('legacy (no type) dropped → null', () => {
|
||||
const raw = { id: 'l2', timestamp: 500, message: 'old', encounterName: 'Enc', undo: { encounterPath: 'enc/e1', updates: {} } };
|
||||
expect(normalizeEvent(raw)).toBeNull();
|
||||
});
|
||||
|
||||
test('explicit unknown type dropped → null', () => {
|
||||
expect(normalizeEvent({ id: 'l3', type: 'unknown' })).toBeNull();
|
||||
});
|
||||
|
||||
test('no type at all dropped → null', () => {
|
||||
expect(normalizeEvent({ id: 'l4' })).toBeNull();
|
||||
expect(normalizeEvent({ path: 'logs/abc' })).toBeNull();
|
||||
});
|
||||
|
||||
test('null in null out', () => {
|
||||
expect(normalizeEvent(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('serialize drops all-legacy input → empty', () => {
|
||||
const raw = [
|
||||
{ id: 'c', timestamp: 300 },
|
||||
{ id: 'a', timestamp: 100 },
|
||||
{ id: 'b', timestamp: 200 },
|
||||
];
|
||||
expect(JSON.parse(serializeEvents(raw))).toEqual([]);
|
||||
});
|
||||
|
||||
test('serialize sorts typed events ascending by ts', () => {
|
||||
const raw = [
|
||||
{ id: 'c', type: 'next_turn', ts: 300 },
|
||||
{ id: 'a', type: 'start_encounter', ts: 100 },
|
||||
{ id: 'b', type: 'damage', ts: 200 },
|
||||
];
|
||||
const arr = JSON.parse(serializeEvents(raw));
|
||||
expect(arr.map(e => e.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('serialize filters nulls + legacy, keeps typed', () => {
|
||||
const arr = JSON.parse(serializeEvents([null, { id: 'x', type: 'damage' }, { id: 'legacy' }, null]));
|
||||
expect(arr).toHaveLength(1);
|
||||
expect(arr[0].id).toBe('x');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
|
||||
// Scans App.js + storage adapters + shared modules. Catches unused imports,
|
||||
// dead code, undefined vars before they hit the browser console.
|
||||
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
const TARGETS = [
|
||||
'src/App.js',
|
||||
'src/storage/contract.js',
|
||||
'src/storage/firebase.js',
|
||||
'src/storage/index.js',
|
||||
'src/storage/server.js',
|
||||
'shared/index.js',
|
||||
'shared/logEvent.js',
|
||||
'shared/turn.js',
|
||||
];
|
||||
|
||||
function runEslint(files) {
|
||||
const abs = files.map(f => `"${path.join(ROOT, f)}"`).join(' ');
|
||||
// execSync throws on non-zero exit (eslint returns 1 when problems found).
|
||||
// Capture stdout regardless.
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execSync(`npx eslint ${abs} --format json`, {
|
||||
encoding: 'utf8',
|
||||
cwd: ROOT,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
}
|
||||
return JSON.parse(stdout || '[]');
|
||||
}
|
||||
|
||||
describe('static guard: eslint clean on prod source', () => {
|
||||
test('zero eslint errors or warnings', () => {
|
||||
const results = runEslint(TARGETS);
|
||||
const problems = [];
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
for (const file of results) {
|
||||
totalErrors += file.errorCount;
|
||||
totalWarnings += file.warningCount;
|
||||
for (const msg of file.messages) {
|
||||
problems.push(` ${path.relative(ROOT, file.filePath)}:${msg.line}:${msg.column} ${msg.ruleId} — ${msg.message}`);
|
||||
}
|
||||
}
|
||||
if (totalErrors + totalWarnings > 0) {
|
||||
const summary = `eslint found ${totalErrors} error(s), ${totalWarnings} warning(s):\n${problems.join('\n')}`;
|
||||
throw new Error(summary);
|
||||
}
|
||||
}, 30000); // eslint spawn can be slow
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
// STATIC GUARD: every mutation logged. Scans shared/turn.js source.
|
||||
// Invariant: any return with patch != null MUST also have log != null.
|
||||
// return { patch: {...}, log: {...} } — OK (logged mutation)
|
||||
// return { patch: null, log: null } — OK (no-op)
|
||||
// return { patch: {...}, log: null } — FAIL (unlogged mutation = BUG-7 class)
|
||||
//
|
||||
// BUG-7 history: reorderParticipants + deathSave + addParticipants +
|
||||
// updateParticipant all returned log:null with real patches. Per-op test
|
||||
// missed them because gaps section asserted null as "expected." Static scan
|
||||
// catches any future regression regardless of test enumeration.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
|
||||
|
||||
// Extract function name + body via brace counting.
|
||||
function collectBody(src, fromIdx) {
|
||||
let i = fromIdx;
|
||||
while (i < src.length && src[i] !== '{') i++;
|
||||
let depth = 0;
|
||||
const start = i;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) break; }
|
||||
}
|
||||
return src.slice(start, i + 1);
|
||||
}
|
||||
|
||||
function extractFunctions(src) {
|
||||
const out = [];
|
||||
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
|
||||
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
|
||||
let m;
|
||||
while ((m = fnRe.exec(src)) !== null) {
|
||||
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
|
||||
}
|
||||
while ((m = arrowRe.exec(src)) !== null) {
|
||||
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Split body into return statements at top level of the function.
|
||||
// Returns array of return-expr strings.
|
||||
function extractReturns(body) {
|
||||
const returns = [];
|
||||
// find `return ` at depth 1 (inside fn body)
|
||||
let i = 0;
|
||||
// body starts with `{`. Skip into depth 1.
|
||||
while (i < body.length && body[i] !== '{') i++;
|
||||
i++; // past {
|
||||
let depth = 1;
|
||||
let start = -1;
|
||||
for (; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
if (depth === 1 && body.slice(i, i + 7) === 'return ') {
|
||||
start = i + 7;
|
||||
}
|
||||
if (depth === 1 && ch === ';' && start !== -1) {
|
||||
returns.push(body.slice(start, i));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
return returns;
|
||||
}
|
||||
|
||||
// For a return expr, detect: has `log: null` AND has `patch:` that is NOT null.
|
||||
// Returns true if suspicious (unlogged mutation).
|
||||
function isUnloggedMutation(retExpr) {
|
||||
if (!/log:\s*null/.test(retExpr)) return false;
|
||||
// patch: null → no-op, OK
|
||||
if (/patch:\s*null/.test(retExpr)) return false;
|
||||
// patch: { ... } or patch: someVar → mutation with null log = BAD
|
||||
if (/patch:/.test(retExpr)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('STATIC: every mutation logged (logging contract)', () => {
|
||||
const fns = extractFunctions(SRC);
|
||||
|
||||
test('no function returns patch!=null with log:null', () => {
|
||||
const offenders = [];
|
||||
for (const { name, body } of fns) {
|
||||
const rets = extractReturns(body);
|
||||
for (const r of rets) {
|
||||
if (isUnloggedMutation(r)) {
|
||||
offenders.push(`${name}: return ${r.trim().slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,16 @@
|
||||
//
|
||||
// Test: walk rounds, count visits. Deactivate+reactivate mid-round must
|
||||
// not cause any participant to act twice in same round.
|
||||
//
|
||||
// New API: mutating funcs are async, take ctx, return newEnc.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant,
|
||||
startEncounter, nextTurn, toggleParticipantActive,
|
||||
} = shared;
|
||||
} = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
@@ -24,35 +26,36 @@ function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
describe('BUG-10: deact+reactivate same round', () => {
|
||||
test('no participant acts twice in a round after deact+reactivate', () => {
|
||||
test('no participant acts twice in a round after deact+reactivate', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// [a(10), b(7), c(3)]
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // a current, r1
|
||||
e = await startEncounter(e, ctx); // a current, r1
|
||||
|
||||
const r1 = [];
|
||||
r1.push(e.currentTurnParticipantId); // a acts
|
||||
e = apply(e, nextTurn(e)); r1.push(e.currentTurnParticipantId); // b acts
|
||||
e = await nextTurn(e, ctx); r1.push(e.currentTurnParticipantId); // b acts
|
||||
|
||||
// b already acted. Deactivate b (NOT current now — b just became current
|
||||
// via nextTurn, so b IS current). Toggle off advances pointer to c.
|
||||
e = apply(e, toggleParticipantActive(e, 'b')); // b off
|
||||
// current advanced to c (b was current)
|
||||
r1.push(e.currentTurnParticipantId); // c "becomes" active turn
|
||||
// reactivate b same round
|
||||
e = apply(e, toggleParticipantActive(e, 'b')); // b on
|
||||
// b already acted and is still current. Deactivate b: status edit only,
|
||||
// no turn advance. DM clicks Next Turn explicitly; nextTurn skips inactive b.
|
||||
e = await toggleParticipantActive(e, 'b', ctx); // b off, current still b
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
e = await nextTurn(e, ctx); // skips inactive b → c
|
||||
r1.push(e.currentTurnParticipantId);
|
||||
// reactivate b same round; b must not re-act before round wraps.
|
||||
e = await toggleParticipantActive(e, 'b', ctx); // b on
|
||||
|
||||
// nextTurn from c → round 2 (a). b must NOT re-act in round 1.
|
||||
e = apply(e, nextTurn(e));
|
||||
e = await nextTurn(e, ctx);
|
||||
expect(e.round).toBe(2);
|
||||
expect(e.currentTurnParticipantId).toBe('a');
|
||||
|
||||
// b acted once in r1, must act once in r2
|
||||
e = apply(e, nextTurn(e));
|
||||
e = await nextTurn(e, ctx);
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
e = apply(e, nextTurn(e));
|
||||
e = await nextTurn(e, ctx);
|
||||
expect(e.currentTurnParticipantId).toBe('c');
|
||||
|
||||
// per-round visit count
|
||||
@@ -60,22 +63,23 @@ describe('BUG-10: deact+reactivate same round', () => {
|
||||
expect(bCountR1).toBe(1);
|
||||
});
|
||||
|
||||
test('deactivate+reactivate non-current who already acted: no re-act', () => {
|
||||
test('deactivate+reactivate non-current who already acted: no re-act', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// [a(10), b(7), c(3)]. a acts, b acts, c acts (round 1 done).
|
||||
// Then deactivate a (already acted, not current since c is current).
|
||||
// Reactivate a. a must not act again until round 2.
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // a
|
||||
e = apply(e, nextTurn(e)); // b
|
||||
e = apply(e, nextTurn(e)); // c (r1 full: a,b,c)
|
||||
e = await startEncounter(e, ctx); // a
|
||||
e = await nextTurn(e, ctx); // b
|
||||
e = await nextTurn(e, ctx); // c (r1 full: a,b,c)
|
||||
// c is current. deactivate a (not current, already acted).
|
||||
e = apply(e, toggleParticipantActive(e, 'a')); // a off, pointer stays c
|
||||
e = await toggleParticipantActive(e, 'a', ctx); // a off, pointer stays c
|
||||
expect(e.currentTurnParticipantId).toBe('c');
|
||||
e = apply(e, toggleParticipantActive(e, 'a')); // a on
|
||||
e = await toggleParticipantActive(e, 'a', ctx); // a on
|
||||
expect(e.currentTurnParticipantId).toBe('c');
|
||||
|
||||
// nextTurn from c → a (round 2). a acts in r2, once.
|
||||
e = apply(e, nextTurn(e));
|
||||
e = await nextTurn(e, ctx);
|
||||
expect(e.round).toBe(2);
|
||||
expect(e.currentTurnParticipantId).toBe('a');
|
||||
});
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
// - acted dragged behind pointer → acts again → DOUBLE-ACT
|
||||
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs
|
||||
// during active encounter. Pre-combat: free reorder (no pointer).
|
||||
//
|
||||
// New API: reorderParticipants is async, takes ctx, returns newEnc.
|
||||
// Blocked drag = no-op (returns same enc ref, no write).
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant,
|
||||
startEncounter, nextTurn, reorderParticipants,
|
||||
} = shared;
|
||||
} = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
@@ -22,51 +25,55 @@ function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
describe('BUG-13: cross-pointer reorder blocked during active encounter', () => {
|
||||
test('dragging not-yet-acted participant ahead of pointer = no-op', () => {
|
||||
test('dragging not-yet-acted participant ahead of pointer = no-op', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// [a(10), b(10), c(10)]. a acts -> b current. c not yet acted.
|
||||
let e = enc([p('a',10),p('b',10),p('c',10)]);
|
||||
e = apply(e, startEncounter(e)); // a (idx0, pointer)
|
||||
e = apply(e, nextTurn(e)); // b (idx1, pointer)
|
||||
e = await startEncounter(e, ctx); // a (idx0, pointer)
|
||||
e = await nextTurn(e, ctx); // b (idx1, pointer)
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
|
||||
// Drag c (idx2, behind pointer) before b (idx1, pointer). Crosses pointer.
|
||||
// Would let c act by initiative-reinsert but nextTurn forward-walk skips.
|
||||
// Blocked instead.
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
expect(r.patch).toBeNull();
|
||||
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||
expect(newEnc).toBe(e); // no-op: same ref, no write
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
|
||||
});
|
||||
|
||||
test('dragging already-acted participant behind pointer = no-op', () => {
|
||||
test('dragging already-acted participant behind pointer = no-op', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// [a(10), b(10), c(10)]. a acts (idx0, ahead of pointer).
|
||||
let e = enc([p('a',10),p('b',10),p('c',10)]);
|
||||
e = apply(e, startEncounter(e)); // a (pointer idx0)
|
||||
e = apply(e, nextTurn(e)); // b (pointer idx1)
|
||||
e = await startEncounter(e, ctx); // a (pointer idx0)
|
||||
e = await nextTurn(e, ctx); // b (pointer idx1)
|
||||
// Drag a (already acted, ahead of pointer) after c (behind pointer).
|
||||
// Crosses pointer. Would let a act again. Blocked.
|
||||
const r = reorderParticipants(e, 'a', 'c');
|
||||
expect(r.patch).toBeNull();
|
||||
const newEnc = await reorderParticipants(e, 'a', 'c', ctx);
|
||||
expect(newEnc).toBe(e); // no-op: same ref, no write
|
||||
});
|
||||
|
||||
test('reorder within same side of pointer = allowed', () => {
|
||||
test('reorder within same side of pointer = allowed', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
// [a(10), b(10), c(10), d(10)]. a current (pointer idx0).
|
||||
// b,c,d all behind pointer (upcoming). Reorder among them = safe.
|
||||
let e = enc([p('a',10),p('b',10),p('c',10),p('d',10)]);
|
||||
e = apply(e, startEncounter(e)); // a (idx0)
|
||||
e = await startEncounter(e, ctx); // a (idx0)
|
||||
// swap b,c (both idx1,2 — behind pointer). No cross.
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
expect(r.patch).not.toBeNull();
|
||||
expect(r.patch.participants.map(p => p.id)).toEqual(['a','c','b','d']);
|
||||
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||
// startEncounter logs too; assert the reorder write specifically.
|
||||
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
|
||||
expect(newEnc.participants.map(p => p.id)).toEqual(['a','c','b','d']);
|
||||
});
|
||||
|
||||
test('pre-combat: free reorder (no pointer)', () => {
|
||||
test('pre-combat: free reorder (no pointer)', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
// isStarted=false. No pointer. Reorder allowed freely.
|
||||
let e = enc([p('a',10),p('b',10),p('c',10)]);
|
||||
const r = reorderParticipants(e, 'c', 'a');
|
||||
expect(r.patch).not.toBeNull();
|
||||
expect(r.patch.participants.map(p => p.id)).toEqual(['c','a','b']);
|
||||
const newEnc = await reorderParticipants(e, 'c', 'a', ctx);
|
||||
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
|
||||
expect(newEnc.participants.map(p => p.id)).toEqual(['c','a','b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// BUG-7: reorderParticipants not logged.
|
||||
// Every combat op returns { patch, log }. Handler calls logAction(log.message,
|
||||
// ctx, { updates: log.undo }) → writes entry to logs collection.
|
||||
// reorderParticipants returns log: null → handler skips logAction → drag
|
||||
// invisible in combat log + no undo payload (siblings all have one).
|
||||
// Every mutating func is now async, takes ctx last, and writes encounter +
|
||||
// log internally via commit(). reorderParticipants must emit a log entry on a
|
||||
// real move (so the drag shows up in the combat log + carries an undo payload)
|
||||
// and stay a silent no-op (no write) when blocked.
|
||||
//
|
||||
// RED: prove log is null (bug). Fix: return { message, undo }.
|
||||
// RED was: returned { patch, log } with log:null → handler skipped logAction.
|
||||
// Now: a successful reorder writes exactly one log entry; no-ops write none.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, reorderParticipants } = shared;
|
||||
const { makeParticipant, reorderParticipants, expandUndo } = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
@@ -21,34 +22,40 @@ function enc(ps) {
|
||||
}
|
||||
|
||||
describe('BUG-7: reorderParticipants logged', () => {
|
||||
test('returns log object (not null) with message + undo', () => {
|
||||
test('returns log object (not null) with message + undo', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
expect(r.patch).not.toBeNull();
|
||||
expect(r.log).not.toBeNull();
|
||||
expect(typeof r.log.message).toBe('string');
|
||||
expect(r.log.message.length).toBeGreaterThan(0);
|
||||
expect(r.log.undo).toBeDefined();
|
||||
await reorderParticipants(e, 'c', 'b', ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
const entry = storage.logs()[0];
|
||||
expect(typeof entry.message).toBe('string');
|
||||
expect(entry.message.length).toBeGreaterThan(0);
|
||||
expect(entry.undo).toBeDefined();
|
||||
});
|
||||
|
||||
test('undo restores original participants order', () => {
|
||||
test('undo restores original participants order', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
|
||||
const orig = e.participants.map(p => p.id);
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
const restored = { ...e, ...r.log.undo };
|
||||
await reorderParticipants(e, 'c', 'b', ctx);
|
||||
const entry = storage.logs()[0];
|
||||
const restored = { ...e, ...expandUndo(entry, e).updates };
|
||||
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||
});
|
||||
|
||||
test('no-op (same id) returns null log', () => {
|
||||
test('no-op (same id) returns null log', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const e = enc([p('a', 10), p('b', 10)]);
|
||||
const r = reorderParticipants(e, 'a', 'a');
|
||||
expect(r.log).toBeNull();
|
||||
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(storage.logs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('no-op (cross-init blocked) returns null log', () => {
|
||||
test('no-op (cross-init blocked) returns null log', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const e = enc([p('a', 10), p('b', 5)]);
|
||||
const r = reorderParticipants(e, 'a', 'b');
|
||||
expect(r.patch).toBeNull();
|
||||
expect(r.log).toBeNull();
|
||||
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(storage.logs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Characterization tests for shared/turn.js.
|
||||
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
|
||||
// These tests assert what the code does NOW, not what it SHOULD do.
|
||||
//
|
||||
// New API: every mutating func is async, takes ctx last, writes encounter +
|
||||
// log internally, returns newEnc. We assert against newEnc (merged state) or
|
||||
// the raw persisted patch (storage.calls) for "field not written" checks.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
@@ -19,6 +23,7 @@ const {
|
||||
endEncounter,
|
||||
makeParticipant,
|
||||
} = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
// Helper: minimal encounter with given participants.
|
||||
function enc(participants = [], extra = {}) {
|
||||
@@ -42,6 +47,13 @@ function p(id, initiative, extra = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Last updateDoc patch written to storage — for "field absent from patch"
|
||||
// assertions (field === undefined means func didn't write it).
|
||||
const lastPatch = (storage) => {
|
||||
const u = storage.calls.filter(c => c.fn === 'updateDoc').pop();
|
||||
return u ? u.data : {};
|
||||
};
|
||||
|
||||
describe('sortParticipantsByInitiative', () => {
|
||||
test('higher initiative first', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
@@ -57,44 +69,51 @@ describe('sortParticipantsByInitiative', () => {
|
||||
});
|
||||
|
||||
describe('startEncounter', () => {
|
||||
test('throws if no participants', () => {
|
||||
expect(() => startEncounter(enc([]))).toThrow('participants');
|
||||
test('throws if no participants', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
await expect(startEncounter(enc([]), ctx)).rejects.toThrow('participants');
|
||||
});
|
||||
|
||||
test('throws if no active participants', () => {
|
||||
test('throws if no active participants', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const e = enc([p('a', 10, { isActive: false })]);
|
||||
expect(() => startEncounter(e)).toThrow('active');
|
||||
await expect(startEncounter(e, ctx)).rejects.toThrow('active');
|
||||
});
|
||||
|
||||
test('sets round 1, turn order sorted, current = highest init', () => {
|
||||
test('sets round 1, turn order sorted, current = highest init', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps);
|
||||
const { patch } = startEncounter(e);
|
||||
expect(patch.isStarted).toBe(true);
|
||||
expect(patch.round).toBe(1);
|
||||
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
const newEnc = await startEncounter(e, ctx);
|
||||
expect(newEnc.isStarted).toBe(true);
|
||||
expect(newEnc.round).toBe(1);
|
||||
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||
expect(newEnc.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
|
||||
test('inactive stays in turn order slot (1-list model)', () => {
|
||||
test('inactive stays in turn order slot (1-list model)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
|
||||
const { patch } = startEncounter(enc(ps));
|
||||
const newEnc = await startEncounter(enc(ps), ctx);
|
||||
// 1-list: all participants sorted by init (active+inactive), inactive stays in slot
|
||||
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||
expect(patch.currentTurnParticipantId).toBe('c'); // b inactive, skipped
|
||||
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||
expect(newEnc.currentTurnParticipantId).toBe('c'); // b inactive, skipped
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextTurn', () => {
|
||||
test('throws if not started', () => {
|
||||
expect(() => nextTurn(enc([p('a', 10)], { isStarted: false }))).toThrow();
|
||||
test('throws if not started', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
await expect(nextTurn(enc([p('a', 10)], { isStarted: false }), ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('throws if paused', () => {
|
||||
expect(() => nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }))).toThrow();
|
||||
test('throws if paused', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
await expect(nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }), ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('advances to next in order, no round bump', () => {
|
||||
test('advances to next in order, no round bump', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
@@ -102,12 +121,13 @@ describe('nextTurn', () => {
|
||||
currentTurnParticipantId: 'b',
|
||||
turnOrderIds: ['b', 'c', 'a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.currentTurnParticipantId).toBe('c');
|
||||
expect(patch.round).toBe(1);
|
||||
const newEnc = await nextTurn(e, ctx);
|
||||
expect(newEnc.currentTurnParticipantId).toBe('c');
|
||||
expect(newEnc.round).toBe(1);
|
||||
});
|
||||
|
||||
test('wraps round when last in order', () => {
|
||||
test('wraps round when last in order', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
@@ -115,12 +135,13 @@ describe('nextTurn', () => {
|
||||
currentTurnParticipantId: 'a',
|
||||
turnOrderIds: ['b', 'c', 'a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
expect(patch.round).toBe(2);
|
||||
const newEnc = await nextTurn(e, ctx);
|
||||
expect(newEnc.currentTurnParticipantId).toBe('b');
|
||||
expect(newEnc.round).toBe(2);
|
||||
});
|
||||
|
||||
test('ends encounter if no active participants', () => {
|
||||
test('ends encounter if no active participants', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { isActive: false })];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
@@ -128,185 +149,204 @@ describe('nextTurn', () => {
|
||||
currentTurnParticipantId: 'a',
|
||||
turnOrderIds: ['a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.isStarted).toBe(false);
|
||||
expect(patch.currentTurnParticipantId).toBe(null);
|
||||
const newEnc = await nextTurn(e, ctx);
|
||||
expect(newEnc.isStarted).toBe(false);
|
||||
expect(newEnc.currentTurnParticipantId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePause', () => {
|
||||
test('pauses started encounter', () => {
|
||||
test('pauses started encounter', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
|
||||
const { patch } = togglePause(e);
|
||||
expect(patch.isPaused).toBe(true);
|
||||
const newEnc = await togglePause(e, ctx);
|
||||
expect(newEnc.isPaused).toBe(true);
|
||||
});
|
||||
|
||||
test('resume preserves turn order (no re-sort)', () => {
|
||||
test('resume preserves turn order (no re-sort)', async () => {
|
||||
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
|
||||
// and caused skips. Order frozen at startEncounter, patched incrementally.
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 5), p('b', 15)];
|
||||
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
|
||||
const { patch } = togglePause(e);
|
||||
expect(patch.isPaused).toBe(false);
|
||||
expect(patch.turnOrderIds).toEqual(['a', 'b']);
|
||||
const newEnc = await togglePause(e, ctx);
|
||||
expect(newEnc.isPaused).toBe(false);
|
||||
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeParticipant', () => {
|
||||
test('removes from participants array', () => {
|
||||
test('removes from participants array', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = removeParticipant(enc(ps), 'a');
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['b']);
|
||||
const newEnc = await removeParticipant(enc(ps), 'a', ctx);
|
||||
expect(newEnc.participants.map(x => x.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('not started: no turn order mutation', () => {
|
||||
test('not started: no turn order mutation', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = removeParticipant(enc(ps), 'a');
|
||||
expect(patch.turnOrderIds).toBeUndefined();
|
||||
await removeParticipant(enc(ps), 'a', ctx);
|
||||
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
|
||||
});
|
||||
|
||||
test('started: removes from turnOrderIds', () => {
|
||||
test('started: removes from turnOrderIds', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||
const { patch } = removeParticipant(e, 'a');
|
||||
expect(patch.turnOrderIds).toEqual(['b']);
|
||||
const newEnc = await removeParticipant(e, 'a', ctx);
|
||||
expect(newEnc.turnOrderIds).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('started: removing current picks next active', () => {
|
||||
test('started: removing current picks next active', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 5), p('c', 3)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
|
||||
const { patch } = removeParticipant(e, 'a');
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
const newEnc = await removeParticipant(e, 'a', ctx);
|
||||
expect(newEnc.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleParticipantActive', () => {
|
||||
test('deactivates participant', () => {
|
||||
test('deactivates participant', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { isActive: true })];
|
||||
const { patch } = toggleParticipantActive(enc(ps), 'a');
|
||||
expect(patch.participants[0].isActive).toBe(false);
|
||||
const newEnc = await toggleParticipantActive(enc(ps), 'a', ctx);
|
||||
expect(newEnc.participants[0].isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('started: deactivating current advances turn', () => {
|
||||
test('started: deactivating current does not advance turn or round', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||
const { patch } = toggleParticipantActive(e, 'a');
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
const e = enc(ps, { isStarted: true, round: 1, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||
const newEnc = await toggleParticipantActive(e, 'a', ctx);
|
||||
expect(newEnc.currentTurnParticipantId).toBe('a');
|
||||
expect(newEnc.round).toBe(1);
|
||||
expect(newEnc.participants.find(p => p.id === 'a').isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('started: reactivating inserts by initiative', () => {
|
||||
test('started: reactivating inserts by initiative', async () => {
|
||||
// BUG-5 fix: reactivated participant slots by initiative (not appended
|
||||
// to end). Preserves correct rotation order.
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
|
||||
const { patch } = toggleParticipantActive(e, 'a');
|
||||
const newEnc = await toggleParticipantActive(e, 'a', ctx);
|
||||
// a init=10 > b init=5 → a slots before b
|
||||
expect(patch.turnOrderIds).toEqual(['a', 'b']);
|
||||
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHpChange', () => {
|
||||
test('damage reduces hp, clamps 0', () => {
|
||||
test('damage reduces hp, clamps 0', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 5);
|
||||
expect(patch.participants[0].currentHp).toBe(10);
|
||||
const newEnc = await applyHpChange(enc(ps), 'a', 'damage', 5, ctx);
|
||||
expect(newEnc.participants[0].currentHp).toBe(10);
|
||||
});
|
||||
|
||||
test('damage to 0 deactivates + keeps turn order (unified)', () => {
|
||||
// Unified: death flips isActive=false (removed from active rotation).
|
||||
// turnOrderIds unchanged (no turn-order patch on death).
|
||||
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
|
||||
test('damage to 0 marks dying, keeps active, keeps turn order', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { type: 'character', currentHp: 3 }), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||
const { patch } = applyHpChange(e, 'a', 'damage', 5);
|
||||
expect(patch.participants[0].currentHp).toBe(0);
|
||||
expect(patch.participants[0].isActive).toBe(false);
|
||||
expect(patch.turnOrderIds).toBeUndefined();
|
||||
expect(patch.currentTurnParticipantId).toBeUndefined();
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
|
||||
expect(newEnc.participants[0].currentHp).toBe(0);
|
||||
expect(newEnc.participants[0].status).toBe('dying');
|
||||
expect(newEnc.participants[0].isActive).not.toBe(false);
|
||||
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
|
||||
expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('heal above 0 reactivates + resets death saves (unified)', () => {
|
||||
// Unified: revive from 0 flips isActive=true, deathSaves reset.
|
||||
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
|
||||
expect(patch.participants[0].currentHp).toBe(5);
|
||||
expect(patch.participants[0].isActive).toBe(true);
|
||||
expect(patch.participants[0].deathSaves).toBe(0);
|
||||
test('heal from dying makes conscious and resets death-save counters', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveSuccesses: 2, deathSaveFailures: 1 })];
|
||||
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx);
|
||||
expect(newEnc.participants[0].currentHp).toBe(5);
|
||||
expect(newEnc.participants[0].status).toBe('conscious');
|
||||
expect(newEnc.participants[0].deathSaveSuccesses).toBe(0);
|
||||
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
|
||||
});
|
||||
|
||||
test('heal clamps to maxHp', () => {
|
||||
test('heal clamps to maxHp', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 10);
|
||||
expect(patch.participants[0].currentHp).toBe(20);
|
||||
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 10, ctx);
|
||||
expect(newEnc.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('zero amount = no-op', () => {
|
||||
const ps = [p('a', 10, { currentHp: 10 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 0);
|
||||
expect(patch).toBe(null);
|
||||
test('zero amount = no-op', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const e = enc([p('a', 10, { currentHp: 10 })]);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 0, ctx);
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deathSave', () => {
|
||||
test('increments fails', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })];
|
||||
const { patch } = deathSave(enc(ps), 'a', 'fail', 1);
|
||||
expect(patch.participants[0].deathFails).toBe(1);
|
||||
test('fail increments failure counter while dying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 0, deathSaveSuccesses: 0 })];
|
||||
const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
|
||||
expect(status).toBe('dying');
|
||||
expect(newEnc.participants[0].deathSaveFailures).toBe(1);
|
||||
});
|
||||
|
||||
test('clicking same fail decrements (toggle)', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
|
||||
const { patch } = deathSave(enc(ps), 'a', 'fail', 2);
|
||||
expect(patch.participants[0].deathFails).toBe(1);
|
||||
});
|
||||
|
||||
test('third fail sets isDying', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
|
||||
const result = deathSave(enc(ps), 'a', 'fail', 3);
|
||||
expect(result.patch.participants[0].deathFails).toBe(3);
|
||||
expect(result.patch.participants[0].isDying).toBe(true);
|
||||
expect(result.isDying).toBe(true);
|
||||
test('third fail makes dead and resets counters', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 2, deathSaveSuccesses: 0 })];
|
||||
const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
|
||||
expect(status).toBe('dead');
|
||||
expect(newEnc.participants[0].status).toBe('dead');
|
||||
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleCondition', () => {
|
||||
test('adds condition', () => {
|
||||
test('adds condition', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { conditions: [] })];
|
||||
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||
expect(patch.participants[0].conditions).toEqual(['poisoned']);
|
||||
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
|
||||
expect(newEnc.participants[0].conditions).toEqual(['poisoned']);
|
||||
});
|
||||
|
||||
test('removes condition', () => {
|
||||
test('removes condition', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
|
||||
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||
expect(patch.participants[0].conditions).toEqual(['blinded']);
|
||||
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
|
||||
expect(newEnc.participants[0].conditions).toEqual(['blinded']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderParticipants', () => {
|
||||
test('drag before target (same-init tie)', () => {
|
||||
test('drag downward onto target moves after target (same-init tie)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||
const { patch } = reorderParticipants(enc(ps), 'a', 'c');
|
||||
// drag a before c: remove a → [b,c], insert before c → [b,a,c]
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
|
||||
const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx);
|
||||
// drag a downward onto c: remove a → [b,c], insert after c → [b,c,a]
|
||||
expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('cross-init drag blocked (no-op)', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = reorderParticipants(enc(ps), 'a', 'b');
|
||||
expect(patch).toBeNull();
|
||||
test('cross-init drag blocked (no-op)', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const e = enc([p('a', 10), p('b', 5)]);
|
||||
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endEncounter', () => {
|
||||
test('resets all combat state', () => {
|
||||
test('resets all combat state', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const e = enc([p('a', 10)], {
|
||||
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
|
||||
});
|
||||
const { patch } = endEncounter(e);
|
||||
expect(patch.isStarted).toBe(false);
|
||||
expect(patch.round).toBe(0);
|
||||
expect(patch.currentTurnParticipantId).toBe(null);
|
||||
expect(patch.turnOrderIds).toEqual([]);
|
||||
const newEnc = await endEncounter(e, ctx);
|
||||
expect(newEnc.isStarted).toBe(false);
|
||||
expect(newEnc.round).toBe(0);
|
||||
expect(newEnc.currentTurnParticipantId).toBe(null);
|
||||
expect(newEnc.turnOrderIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,19 +366,21 @@ describe('computeTurnOrderAfterRemoval', () => {
|
||||
});
|
||||
|
||||
describe('addParticipant', () => {
|
||||
test('appends participant', () => {
|
||||
test('appends participant', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const np = p('z', 7);
|
||||
const { patch } = addParticipant(enc([p('a', 10)]), np);
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']);
|
||||
const newEnc = await addParticipant(enc([p('a', 10)]), np, ctx);
|
||||
expect(newEnc.participants.map(x => x.id)).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
test('rejects duplicate id (skip-bug root cause)', () => {
|
||||
test('rejects duplicate id (skip-bug root cause)', async () => {
|
||||
// Two participants with same id → togglePause resume rebuilds order with
|
||||
// dup id twice → nextTurn gets stuck repeating that id forever.
|
||||
// Audit found this in 100-round replay (addParticipant fired while paused
|
||||
// because nextTurn threw, loop spun, same totalTurns %10 → re-added).
|
||||
const { ctx } = mockCtx();
|
||||
const existing = p('x', 5);
|
||||
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
|
||||
expect(() => addParticipant(enc([p('a', 10), existing]), dup)).toThrow();
|
||||
await expect(addParticipant(enc([p('a', 10), existing]), dup, ctx)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Combat integrity test: replay exact op sequence through pure turn.js,
|
||||
// Combat integrity test: replay exact op sequence through async turn.js,
|
||||
// assert rotation + state invariants per round. This IS the test the audit
|
||||
// was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5.
|
||||
//
|
||||
@@ -6,8 +6,11 @@
|
||||
// damage, heal (cleric), conditions, toggleActive, deathSave,
|
||||
// removeParticipant, addParticipant, updateParticipant, pause/resume,
|
||||
// reorderParticipants, revive-between-rounds.
|
||||
//
|
||||
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
@@ -53,7 +56,7 @@ function setupEncounter() {
|
||||
buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant,
|
||||
buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant,
|
||||
buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant,
|
||||
buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, isNpc:true }).participant,
|
||||
buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, asNpc:true }).participant,
|
||||
];
|
||||
// give deterministic ids to monsters for assertions
|
||||
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' };
|
||||
@@ -70,22 +73,17 @@ function currentParticipant(e) {
|
||||
return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null;
|
||||
}
|
||||
|
||||
// Apply a result patch if present.
|
||||
function apply(e, result) {
|
||||
if (!result || !result.patch) return e;
|
||||
return { ...e, ...result.patch };
|
||||
}
|
||||
|
||||
describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const ROUNDS = 100;
|
||||
const violations = [];
|
||||
|
||||
test('every round visits each active participant exactly once', () => {
|
||||
test('every round visits each active participant exactly once', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
_seed = 12345; // reset for reproducibility
|
||||
let e = setupEncounter();
|
||||
e = apply(e, startEncounter(e));
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
let totalTurns = 0;
|
||||
let lastPaused = false;
|
||||
@@ -102,15 +100,15 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
|
||||
while (e.round === startRound && guard < cap) {
|
||||
// resume if paused (must precede nextTurn)
|
||||
if (lastPaused) { e = apply(e, togglePause(e)); lastPaused = false; }
|
||||
if (lastPaused) { e = await togglePause(e, ctx); lastPaused = false; }
|
||||
|
||||
// advance
|
||||
let t;
|
||||
try { t = nextTurn(e); } catch (err) {
|
||||
try {
|
||||
e = await nextTurn(e, ctx);
|
||||
} catch (err) {
|
||||
violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message });
|
||||
break;
|
||||
}
|
||||
e = apply(e, t);
|
||||
totalTurns++;
|
||||
// only count if turn belongs to THIS round (no wrap)
|
||||
if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId);
|
||||
@@ -120,58 +118,58 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
// 1. damage
|
||||
if (actor) {
|
||||
const foes = e.participants.filter(
|
||||
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false
|
||||
part => part.id !== actor.id && part.currentHp > 0 && part.isActive !== false
|
||||
);
|
||||
if (foes.length > 0) {
|
||||
const tgt = pick(foes);
|
||||
const dmg = 1 + rnd(5);
|
||||
e = apply(e, applyHpChange(e, tgt.id, 'damage', dmg));
|
||||
e = await applyHpChange(e, tgt.id, 'damage', dmg, ctx);
|
||||
}
|
||||
}
|
||||
// 2. heal (cleric)
|
||||
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||
const wounded = e.participants
|
||||
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
|
||||
.filter(part => part.currentHp > 0 && part.currentHp < part.maxHp && part.isActive !== false)
|
||||
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
||||
if (wounded.length > 0) {
|
||||
const tgt = wounded[0];
|
||||
const amt = 2 + rnd(5);
|
||||
e = apply(e, applyHpChange(e, tgt.id, 'heal', amt));
|
||||
e = await applyHpChange(e, tgt.id, 'heal', amt, ctx);
|
||||
}
|
||||
}
|
||||
// 3. conditions
|
||||
if (condQueue.length > 0) {
|
||||
const cond = condQueue[0];
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, toggleCondition(e, tgt.id, cond)); condQueue.shift(); }
|
||||
try { e = await toggleCondition(e, tgt.id, cond, ctx); condQueue.shift(); }
|
||||
catch (err) { condQueue.shift(); }
|
||||
}
|
||||
} else if (totalTurns % 6 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
|
||||
try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {}
|
||||
try { e = await toggleCondition(e, tgt.id, cond, ctx); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 4. toggleParticipantActive
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
const living = e.participants.filter(part => part.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, toggleParticipantActive(e, tgt.id)); } catch (err) {}
|
||||
try { e = await toggleParticipantActive(e, tgt.id, ctx); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 5. deathSave
|
||||
if (actor && actor.currentHp <= 0 && !actor.isNpc) {
|
||||
try { e = apply(e, deathSave(e, actor.id, 'fail', 1)); } catch (err) {}
|
||||
if (actor && actor.status === 'dying' && actor.type !== 'npc') {
|
||||
try { const r = await deathSave(e, actor.id, 'fail', ctx); e = r.enc; } catch (err) {}
|
||||
}
|
||||
// 6. removeParticipant
|
||||
// 6. removeParticipant (monsters/NPC only; dead PCs stay until DM removes manually)
|
||||
if (totalTurns % 5 === 0) {
|
||||
const dead = e.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
|
||||
if (dead) { try { e = apply(e, removeParticipant(e, dead.id)); } catch (err) {} }
|
||||
const dead = e.participants.find(part => (part.status === 'dead' || part.currentHp <= 0) && (part.type === 'npc' || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf'));
|
||||
if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} }
|
||||
}
|
||||
// 7. addParticipant
|
||||
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
|
||||
@@ -180,27 +178,27 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
{ name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 },
|
||||
]);
|
||||
const built = buildMonsterParticipant(spec).participant;
|
||||
try { e = apply(e, addParticipant(e, built)); reinforcementsAdded++; } catch (err) {}
|
||||
try { e = await addParticipant(e, built, ctx); reinforcementsAdded++; } catch (err) {}
|
||||
}
|
||||
// 8. updateParticipant
|
||||
if (totalTurns % 7 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
const living = e.participants.filter(part => part.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` })); } catch (err) {}
|
||||
try { e = await updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` }, ctx); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 9. pause
|
||||
if (totalTurns % 12 === 0 && !lastPaused) { e = apply(e, togglePause(e)); lastPaused = true; }
|
||||
if (totalTurns % 12 === 0 && !lastPaused) { e = await togglePause(e, ctx); lastPaused = true; }
|
||||
// 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op)
|
||||
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
|
||||
if (living.length >= 2) {
|
||||
const tgt = living[0];
|
||||
const newInit = (tgt.initiative || 0) + 1;
|
||||
try {
|
||||
const reordered = [...e.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p);
|
||||
e = apply(e, reorderParticipants(e, reordered));
|
||||
const reordered = [...e.participants].map(part => part.id === tgt.id ? { ...part, initiative: newInit } : part);
|
||||
e = await reorderParticipants(e, reordered); // array as draggedId throws → swallowed
|
||||
lastReorder = totalTurns;
|
||||
} catch (err) {}
|
||||
}
|
||||
@@ -215,20 +213,19 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
const uniq = new Set(seenThisRound);
|
||||
if (uniq.size !== seenThisRound.length) {
|
||||
violations.push({ round: roundN, type: 'rotation-dupe',
|
||||
seen: seenThisRound.map(id => e.participants.find(p=>p.id===id)?.name||id) });
|
||||
seen: seenThisRound.map(id => e.participants.find(part=>part.id===id)?.name||id) });
|
||||
}
|
||||
// turnOrderIds no dup
|
||||
const orderUniq = new Set(e.turnOrderIds);
|
||||
if (orderUniq.size !== e.turnOrderIds.length) {
|
||||
violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds });
|
||||
}
|
||||
// currentTurn valid + active
|
||||
// currentTurn valid. It may be inactive immediately after DM toggles
|
||||
// current actor inactive; toggle-active is status edit, not turn advance.
|
||||
// Next Turn is responsible for skipping inactive current.
|
||||
if (e.currentTurnParticipantId) {
|
||||
const ct = e.participants.find(p => p.id === e.currentTurnParticipantId);
|
||||
const ct = e.participants.find(part => part.id === e.currentTurnParticipantId);
|
||||
if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' });
|
||||
else if (ct.isActive === false && e.isStarted) {
|
||||
violations.push({ round: roundN, type: 'currentTurn-inactive', id: ct.id });
|
||||
}
|
||||
}
|
||||
// HP bounds
|
||||
for (const part of e.participants) {
|
||||
@@ -247,11 +244,11 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
// queue drains them through toggleCondition which proves acceptance)
|
||||
|
||||
// revive dead between rounds
|
||||
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
const dead = e.participants.filter(part => part.currentHp <= 0 || part.isActive === false);
|
||||
for (const d of dead) {
|
||||
try {
|
||||
if (d.isActive === false) e = apply(e, toggleParticipantActive(e, d.id));
|
||||
e = apply(e, applyHpChange(e, d.id, 'heal', d.maxHp));
|
||||
if (d.isActive === false) e = await toggleParticipantActive(e, d.id, ctx);
|
||||
e = await applyHpChange(e, d.id, 'heal', d.maxHp, ctx);
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
@@ -270,25 +267,26 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
|
||||
// Custom (freeform) condition strings: UI contract.
|
||||
// toggleCondition must accept ANY string, not just built-ins.
|
||||
test('custom condition strings survive add/remove round-trip', () => {
|
||||
test('custom condition strings survive add/remove round-trip', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = setupEncounter();
|
||||
e = apply(e, startEncounter(e));
|
||||
e = await startEncounter(e, ctx);
|
||||
const tgt = e.participants[0].id;
|
||||
|
||||
// add custom (not in built-ins)
|
||||
e = apply(e, toggleCondition(e, tgt, 'hexed'));
|
||||
let p = e.participants.find(x => x.id === tgt);
|
||||
expect(p.conditions).toContain('hexed');
|
||||
e = await toggleCondition(e, tgt, 'hexed', ctx);
|
||||
let part = e.participants.find(x => x.id === tgt);
|
||||
expect(part.conditions).toContain('hexed');
|
||||
|
||||
// emoji-bearing custom id
|
||||
e = apply(e, toggleCondition(e, tgt, '🛡️blessed'));
|
||||
p = e.participants.find(x => x.id === tgt);
|
||||
expect(p.conditions).toContain('🛡️blessed');
|
||||
e = await toggleCondition(e, tgt, '🛡️blessed', ctx);
|
||||
part = e.participants.find(x => x.id === tgt);
|
||||
expect(part.conditions).toContain('🛡️blessed');
|
||||
|
||||
// remove
|
||||
e = apply(e, toggleCondition(e, tgt, 'hexed'));
|
||||
p = e.participants.find(x => x.id === tgt);
|
||||
expect(p.conditions).not.toContain('hexed');
|
||||
expect(p.conditions).toContain('🛡️blessed');
|
||||
e = await toggleCondition(e, tgt, 'hexed', ctx);
|
||||
part = e.participants.find(x => x.id === tgt);
|
||||
expect(part.conditions).not.toContain('hexed');
|
||||
expect(part.conditions).toContain('🛡️blessed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
// Unified behavior (App main): death flips isActive=false, dead participant
|
||||
// removed from active rotation, skipped by nextTurn. deathSave is a manual
|
||||
// DM action (button), not tied to rotation. turnOrderIds unchanged on death
|
||||
// (only isActive flag flips). Revive (heal from 0) reactivates.
|
||||
//
|
||||
// Previous "M4: dead stays in rotation" concept reversed by unification.
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 100, currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
function pc(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'character',
|
||||
initiative: init, maxHp: 100, currentHp: 100,
|
||||
id,
|
||||
name: id,
|
||||
type: 'character',
|
||||
initiative: init,
|
||||
maxHp: 100,
|
||||
currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
@@ -26,65 +19,67 @@ function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
const byId = (e, id) => e.participants.find(p => p.id === id);
|
||||
|
||||
describe('unified: death deactivates, dead skipped in rotation', () => {
|
||||
test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => {
|
||||
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
describe('death state: participant remains in initiative and encounter', () => {
|
||||
test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const orderBefore = e.turnOrderIds.slice();
|
||||
// kill b
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
|
||||
expect(byId(e, 'b').status).toBe('dying');
|
||||
expect(byId(e, 'b').isActive).not.toBe(false);
|
||||
expect(e.participants.map(p => p.id)).toContain('b');
|
||||
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
test('dead PC skipped by nextTurn (isActive=false)', () => {
|
||||
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
// kill b
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||
// advance: a→c (b skipped, inactive)
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
expect(e.currentTurnParticipantId).toBe('c');
|
||||
test('dead PC remains in participants and turnOrderIds after three failures', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const orderBefore = e.turnOrderIds.slice();
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
|
||||
expect(byId(e, 'b').status).toBe('dead');
|
||||
expect(byId(e, 'b').isActive).not.toBe(false);
|
||||
expect(e.participants.map(p => p.id)).toContain('b');
|
||||
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
test('dead PC deathSave fires on manual call (not via rotation)', () => {
|
||||
const ps = [pc('a', 20), pc('b', 15)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
// kill b (current = a)
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||
// b is dead: DM can still fire deathSave (manual action)
|
||||
const r = deathSave(e, 'b', 'fail', 1);
|
||||
expect(r.patch).toBeTruthy();
|
||||
const b = r.patch.participants.find(x => x.id === 'b');
|
||||
expect(b.deathFails).toBe(1);
|
||||
test('nextTurn may still land on dead PC because DM/tool events may matter', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
|
||||
e = await nextTurn(e, ctx);
|
||||
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
expect(byId(e, 'b').status).toBe('dead');
|
||||
});
|
||||
|
||||
// D1 unification: shared now matches App main — death flips isActive=false.
|
||||
// Dead participant removed from active rotation (skipped by nextTurn).
|
||||
test('dead PC auto-set isActive=false by applyHpChange', () => {
|
||||
const ps = [pc('a', 20), pc('b', 15)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||
const b = e.participants.find(x => x.id === 'b');
|
||||
expect(b.isActive).toBe(false);
|
||||
});
|
||||
test('deathSave rejected/no-op once dead', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([pc('a', 20), pc('b', 15)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||
const before = byId(e, 'b');
|
||||
|
||||
test('revive (heal from 0) reactivates participant', () => {
|
||||
const ps = [pc('a', 20), pc('b', 15)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||
const dead = e.participants.find(x => x.id === 'b');
|
||||
expect(dead.isActive).toBe(false);
|
||||
// heal b back up
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'heal', 50).patch };
|
||||
const revived = e.participants.find(x => x.id === 'b');
|
||||
expect(revived.isActive).toBe(true);
|
||||
expect(revived.currentHp).toBe(50);
|
||||
expect(revived.deathSaves).toBe(0);
|
||||
try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
|
||||
|
||||
expect(byId(e, 'b')).toMatchObject(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,90 +1,334 @@
|
||||
// Death saves: broken model. Current = single counter, 3=dying. D&D 5e needs
|
||||
// separate success/fail tracking:
|
||||
// 3 successes = stable (0hp unconscious, safe until healed)
|
||||
// 3 failures = dead
|
||||
// Current tracks "saves" (really fails via red X UI), no successes at all.
|
||||
// "Stable" state doesn't exist.
|
||||
//
|
||||
// RED: prove current can't model 3-success stability.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, applyHpChange, deathSave } = shared;
|
||||
const {
|
||||
makeParticipant,
|
||||
startEncounter,
|
||||
applyHpChange,
|
||||
deathSave,
|
||||
stabilizeParticipant,
|
||||
reviveParticipant,
|
||||
} = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id) {
|
||||
return makeParticipant({ id, name: id, type: 'character',
|
||||
initiative: 10, maxHp: 100, currentHp: 100 });
|
||||
function pc(id, extra = {}) {
|
||||
return makeParticipant({
|
||||
id,
|
||||
name: id,
|
||||
type: 'character',
|
||||
initiative: 10,
|
||||
maxHp: 100,
|
||||
currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
function enc(ps, extra = {}) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||
function monster(id, extra = {}) {
|
||||
return makeParticipant({
|
||||
id,
|
||||
name: id,
|
||||
type: 'monster',
|
||||
initiative: 10,
|
||||
maxHp: 100,
|
||||
currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
describe('death saves: missing success/stable model', () => {
|
||||
test('3 successes makes character stable (not dead, not dying)', () => {
|
||||
// Character at 0hp. Roll 3 successful death saves.
|
||||
let e = enc([p('a')]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, applyHpChange(e, 'a', 'damage', 100)); // 0hp
|
||||
expect(e.participants[0].currentHp).toBe(0);
|
||||
function enc(participants, extra = {}) {
|
||||
return {
|
||||
id: 'e1',
|
||||
name: 'death-save-test',
|
||||
participants,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// Roll 3 successful saves. Should mark stable.
|
||||
e = apply(e, deathSave(e, 'a', 'success', 1));
|
||||
e = apply(e, deathSave(e, 'a', 'success', 2));
|
||||
const r3 = deathSave(e, 'a', 'success', 3);
|
||||
e = apply(e, r3);
|
||||
async function startedAtZero() {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([pc('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const originalTurnOrderIds = [...e.turnOrderIds];
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||
return { e, ctx, originalTurnOrderIds };
|
||||
}
|
||||
|
||||
// RED: current deathSave signature = (enc, id, saveNumber). No type arg.
|
||||
// Will throw or misbehave. Want: status field.
|
||||
expect(r3.status).toBe('stable');
|
||||
expect(e.participants[0].isDying).toBe(false);
|
||||
expect(e.participants[0].isStable).toBe(true);
|
||||
expect(e.participants[0].deathFails).toBe(0);
|
||||
const part = e => e.participants.find(p => p.id === 'a');
|
||||
|
||||
describe('death saves: D&D 5e status state machine', () => {
|
||||
test('damage drops character to dying without removal or deactivation', async () => {
|
||||
const { e, originalTurnOrderIds } = await startedAtZero();
|
||||
const p = part(e);
|
||||
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('dying');
|
||||
expect(p.deathSaveSuccesses).toBe(0);
|
||||
expect(p.deathSaveFailures).toBe(0);
|
||||
expect(p.isActive).not.toBe(false);
|
||||
expect(e.participants.map(x => x.id)).toContain('a');
|
||||
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
|
||||
});
|
||||
|
||||
test('3 failures makes character dead (isDying for removal)', () => {
|
||||
let e = enc([p('a')]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, applyHpChange(e, 'a', 'damage', 100));
|
||||
test('damage drops non-NPC monster to dead/inactive, not dying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([monster('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const orderBefore = [...e.turnOrderIds];
|
||||
|
||||
const r1 = deathSave(e, 'a', 'fail', 1);
|
||||
e = apply(e, r1);
|
||||
const r2 = deathSave(e, 'a', 'fail', 2);
|
||||
e = apply(e, r2);
|
||||
const r3 = deathSave(e, 'a', 'fail', 3);
|
||||
e = apply(e, r3);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||
|
||||
expect(r3.status).toBe('dead');
|
||||
expect(e.participants[0].isDying).toBe(true);
|
||||
expect(e.participants[0].deathFails).toBe(3);
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
expect(part(e).conditions).not.toContain('unconscious');
|
||||
expect(part(e).isActive).toBe(false);
|
||||
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
test('successes and fails tracked independently', () => {
|
||||
// Mixed: 1 success, 2 fails, then 2 more successes = stable.
|
||||
let e = enc([p('a')]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, applyHpChange(e, 'a', 'damage', 100));
|
||||
test('dead non-NPC monster with stale active flag repairs to inactive', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([monster('a', { currentHp: 0, status: 'dead', isActive: true })]);
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
e = apply(e, deathSave(e, 'a', 'success', 1));
|
||||
expect(e.participants[0].deathSaves).toBe(1);
|
||||
expect(e.participants[0].deathFails).toBe(0);
|
||||
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||
|
||||
e = apply(e, deathSave(e, 'a', 'fail', 1));
|
||||
e = apply(e, deathSave(e, 'a', 'fail', 2));
|
||||
expect(e.participants[0].deathSaves).toBe(1);
|
||||
expect(e.participants[0].deathFails).toBe(2);
|
||||
|
||||
// 2 more successes → total 3 successes → stable
|
||||
e = apply(e, deathSave(e, 'a', 'success', 2));
|
||||
const r = deathSave(e, 'a', 'success', 3);
|
||||
expect(r.status).toBe('stable');
|
||||
expect(e.participants[0].deathFails).toBe(2); // fails unchanged
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('deathSave signature: (enc, id, type, n)', () => {
|
||||
// type = 'success' | 'fail'. n = 1|2|3.
|
||||
expect(deathSave.length).toBe(4);
|
||||
test('damage drops NPC to dying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([monster('a', { type: 'npc' })]);
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||
|
||||
expect(part(e).type).toBe('npc');
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).conditions).toContain('unconscious');
|
||||
});
|
||||
|
||||
test('three failures immediately makes dead, resets counters, keeps participant and turn order', async () => {
|
||||
let { e, ctx, originalTurnOrderIds } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(1);
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(2);
|
||||
|
||||
const res = await deathSave(e, 'a', 'fail', ctx);
|
||||
e = res.enc;
|
||||
expect(res.status).toBe('dead');
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).currentHp).toBe(0);
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
expect(part(e).isActive).not.toBe(false);
|
||||
expect(e.participants.map(x => x.id)).toContain('a');
|
||||
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
|
||||
});
|
||||
|
||||
test('three successes immediately makes stable and resets counters', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
const res = await deathSave(e, 'a', 'success', ctx);
|
||||
e = res.enc;
|
||||
|
||||
expect(res.status).toBe('stable');
|
||||
expect(part(e).status).toBe('stable');
|
||||
expect(part(e).currentHp).toBe(0);
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
});
|
||||
|
||||
test('successes and failures are independent before terminal transition', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(2);
|
||||
expect(part(e).deathSaveSuccesses).toBe(2);
|
||||
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
expect(part(e).status).toBe('stable');
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
});
|
||||
|
||||
test('nat1 adds two failures and can kill', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(2);
|
||||
|
||||
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
});
|
||||
|
||||
test('nat1 at one failure immediately kills', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
|
||||
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
});
|
||||
|
||||
test('nat20 restores to 1 HP conscious and resets counters', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'nat20', ctx)).enc;
|
||||
|
||||
expect(part(e).status).toBe('conscious');
|
||||
expect(part(e).currentHp).toBe(1);
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
});
|
||||
|
||||
test('damage while dying adds one failure', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(1);
|
||||
});
|
||||
|
||||
test('critical damage while dying adds two failures', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 1, { isCriticalHit: true }, ctx);
|
||||
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(2);
|
||||
});
|
||||
|
||||
test('damage while stable returns to dying and applies failure', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
expect(part(e).status).toBe('stable');
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(1);
|
||||
});
|
||||
|
||||
test('healing while dying or stable makes conscious and clears counters', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||
expect(part(e).status).toBe('conscious');
|
||||
expect(part(e).currentHp).toBe(10);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||
expect(part(e).status).toBe('stable');
|
||||
|
||||
e = await applyHpChange(e, 'a', 'heal', 5, ctx);
|
||||
expect(part(e).status).toBe('conscious');
|
||||
expect(part(e).currentHp).toBe(5);
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
});
|
||||
|
||||
test('normal healing does not revive dead', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
expect(part(e).status).toBe('dead');
|
||||
|
||||
try { e = await applyHpChange(e, 'a', 'heal', 10, ctx); } catch (err) {}
|
||||
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).currentHp).toBe(0);
|
||||
});
|
||||
|
||||
test('stabilize sets stable, hp zero, counters zero', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = await stabilizeParticipant(e, 'a', ctx);
|
||||
|
||||
expect(part(e).status).toBe('stable');
|
||||
expect(part(e).currentHp).toBe(0);
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
});
|
||||
|
||||
test('negative heal acts as damage and can drop to dying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([pc('a', { currentHp: 1 })]);
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
e = await applyHpChange(e, 'a', 'heal', -1, ctx);
|
||||
|
||||
expect(part(e).currentHp).toBe(0);
|
||||
expect(part(e).status).toBe('dying');
|
||||
});
|
||||
|
||||
test('negative damage acts as healing', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', -5, ctx);
|
||||
|
||||
expect(part(e).currentHp).toBe(5);
|
||||
expect(part(e).status).toBe('conscious');
|
||||
});
|
||||
|
||||
test('revive moves dead participant to 0 HP stable/unconscious', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
expect(part(e).status).toBe('dead');
|
||||
|
||||
e = await reviveParticipant(e, 'a', ctx);
|
||||
|
||||
expect(part(e).currentHp).toBe(0);
|
||||
expect(part(e).status).toBe('stable');
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
expect(part(e).conditions).toContain('unconscious');
|
||||
expect(part(e).isActive).toBe(true);
|
||||
});
|
||||
|
||||
test('death save outside dying state is rejected or no-op', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([pc('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
try { e = (await deathSave(e, 'a', 'fail', ctx)).enc; } catch (err) {}
|
||||
|
||||
expect(part(e).status).toBe('conscious');
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Undo roundtrip for DEATH paths: damage to 0 HP (dying/dead), deathSave,
|
||||
// stabilize, revive, dead-monster auto-deactivate. Each must restore full
|
||||
// prior state including status, death-save counters, conditions (unconscious),
|
||||
// and isActive.
|
||||
//
|
||||
// Pattern: apply op (writes encounter + log) -> read last log -> expandUndo
|
||||
// against newEnc -> merge -> deepEqual original snapshot.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const { expandUndo } = shared;
|
||||
const {
|
||||
makeParticipant, startEncounter,
|
||||
applyHpChange, deathSave, stabilizeParticipant, reviveParticipant,
|
||||
} = shared;
|
||||
|
||||
function char(id, hp = 100) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'character',
|
||||
initiative: 10, maxHp: hp, currentHp: hp,
|
||||
});
|
||||
}
|
||||
function mon(id, hp = 100) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: 10, maxHp: hp, currentHp: hp,
|
||||
});
|
||||
}
|
||||
function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||
|
||||
function lastUndo(storage, enc) {
|
||||
const logs = storage.logs();
|
||||
const last = logs[logs.length - 1];
|
||||
expect(last.undo).toBeTruthy();
|
||||
const expanded = expandUndo(last, enc);
|
||||
expect(expanded).toBeTruthy();
|
||||
return expanded.updates;
|
||||
}
|
||||
|
||||
describe('death-path undo roundtrip', () => {
|
||||
test('damage to 0 HP (dying) undo restores status + unconscious condition', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const before = snap(e);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('dying');
|
||||
expect(p.conditions).toContain('unconscious');
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('damage while dying undo restores status + failures + conditions', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||
const before = snap(e);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); // +1 fail
|
||||
expect(newEnc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('damage while stable undo restores stable + reset failures', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||
e = await stabilizeParticipant(e, 'a', ctx); // -> stable
|
||||
const before = snap(e);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx); // -> dying 1 fail
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('massive damage (dead) undo restores conscious', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a', 50)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const before = snap(e);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 200, ctx);
|
||||
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('dead');
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('dead monster auto-deactivate undo restores active', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([mon('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
// first make it dead+inactive
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dead + inactive
|
||||
// second damage at 0 while dead: no-op (returns same enc) but if it had
|
||||
// repaired a stale active-dead monster it writes deactivate_dead_monster.
|
||||
// Force the repair path: set stale isActive=true then damage.
|
||||
e = { ...e, participants: e.participants.map(p => p.id === 'a' ? { ...p, isActive: true } : p) };
|
||||
const before = snap(e); // stale active-dead = state right before the op
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||
expect(newEnc.participants.find(x => x.id === 'a').isActive).toBe(false);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('deathSave fail undo restores dying + counters + conditions', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||
const before = snap(e);
|
||||
const r = await deathSave(e, 'a', 'fail', ctx);
|
||||
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
|
||||
const undo = lastUndo(storage, r.enc);
|
||||
const after = { ...r.enc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('deathSave nat1 (2 fails) undo restores prior counters', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||
const before = snap(e);
|
||||
const r = await deathSave(e, 'a', 'nat1', ctx);
|
||||
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(2);
|
||||
const undo = lastUndo(storage, r.enc);
|
||||
const after = { ...r.enc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('deathSave nat20 (conscious) undo restores dying + unconscious', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||
const before = snap(e);
|
||||
const r = await deathSave(e, 'a', 'nat20', ctx);
|
||||
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('conscious');
|
||||
expect(r.enc.participants.find(x => x.id === 'a').currentHp).toBe(1);
|
||||
const undo = lastUndo(storage, r.enc);
|
||||
const after = { ...r.enc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('deathSave to stable undo restores dying + unconscious', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
|
||||
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
|
||||
const before = snap(e); // 2 successes, dying
|
||||
const r = await deathSave(e, 'a', 'success', ctx); // -> stable
|
||||
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('stable');
|
||||
const undo = lastUndo(storage, r.enc);
|
||||
const after = { ...r.enc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('stabilize undo restores dying + unconscious + counters', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||
e = await deathSave(e, 'a', 'fail', ctx).then(x => x.enc); // 1 fail
|
||||
const before = snap(e);
|
||||
const newEnc = await stabilizeParticipant(e, 'a', ctx);
|
||||
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
|
||||
test('revive undo restores dead + isActive + counters', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([char('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 200, ctx); // -> dead (active)
|
||||
const before = snap(e);
|
||||
const newEnc = await reviveParticipant(e, 'a', ctx);
|
||||
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
// DRY guard (BUG-5 fix): nextTurn and computeTurnOrderAfterRemoval share one
|
||||
// advance core (nextActiveAfter). Both must pick the SAME next-active target
|
||||
// for identical state. If this goes RED, the two paths drifted.
|
||||
// Toggle-active semantics guard.
|
||||
// Design: toggle active is a status edit, NOT a turn action.
|
||||
// Deactivating current does not advance turn or increment round. DM clicks
|
||||
// Next Turn explicitly; Next Turn skips inactive participants.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, toggleParticipantActive } = shared;
|
||||
const { makeParticipant, nextTurn, toggleParticipantActive } = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
@@ -16,37 +17,36 @@ function enc(ps, extra = {}) {
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||
}
|
||||
|
||||
describe('DRY: deact-current advance == nextTurn advance', () => {
|
||||
test('mid-round: same target (not current)', () => {
|
||||
// order a,b,c. a current. nextTurn → b. deact a → advance → b.
|
||||
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true,
|
||||
describe('toggle-active is not a turn advance', () => {
|
||||
test('mid-round: deactivating current leaves current unchanged', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
|
||||
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
|
||||
const nt = nextTurn(e).patch.currentTurnParticipantId;
|
||||
const deact = toggleParticipantActive(e, 'a').patch.currentTurnParticipantId;
|
||||
expect(deact).toBe(nt);
|
||||
expect(deact).toBe('b');
|
||||
});
|
||||
|
||||
test('mid-round with inactive skipper: same target', () => {
|
||||
// order a,x,b,c; x inactive. a current. nextTurn → b. deact a → b.
|
||||
const x = p('x',7,{ isActive:false });
|
||||
const e = enc([p('a',10),x,p('b',5),p('c',3)], { isStarted:true,
|
||||
turnOrderIds:['a','x','b','c'], currentTurnParticipantId:'a' });
|
||||
const nt = nextTurn(e).patch.currentTurnParticipantId;
|
||||
const deact = toggleParticipantActive(e, 'a').patch.currentTurnParticipantId;
|
||||
expect(deact).toBe(nt);
|
||||
expect(deact).toBe('b');
|
||||
});
|
||||
|
||||
test('wrap: same target + round bump', () => {
|
||||
// order a,b,c. c current. nextTurn → wrap → a (r+1). deact c → wrap → a (r+1).
|
||||
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true,
|
||||
turnOrderIds:['a','b','c'], currentTurnParticipantId:'c', round:2 });
|
||||
const nt = nextTurn(e).patch;
|
||||
const deact = toggleParticipantActive(e, 'c').patch;
|
||||
expect(deact.currentTurnParticipantId).toBe(nt.currentTurnParticipantId);
|
||||
const deact = await toggleParticipantActive(e, 'a', ctx);
|
||||
expect(deact.currentTurnParticipantId).toBe('a');
|
||||
expect(deact.round).toBe(nt.round);
|
||||
expect(deact.round).toBe(3);
|
||||
expect(deact.round).toBe(1);
|
||||
expect(deact.participants.find(p => p.id === 'a').isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('nextTurn after deactivating current skips inactive participant', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
|
||||
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
|
||||
const deact = await toggleParticipantActive(e, 'a', ctx);
|
||||
const next = await nextTurn(deact, ctx);
|
||||
expect(next.currentTurnParticipantId).toBe('b');
|
||||
expect(next.round).toBe(1);
|
||||
});
|
||||
|
||||
test('wrap: deactivating current does not increment round; nextTurn does', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:2,
|
||||
turnOrderIds:['a','b','c'], currentTurnParticipantId:'c' });
|
||||
const deact = await toggleParticipantActive(e, 'c', ctx);
|
||||
expect(deact.currentTurnParticipantId).toBe('c');
|
||||
expect(deact.round).toBe(2);
|
||||
const next = await nextTurn(deact, ctx);
|
||||
expect(next.currentTurnParticipantId).toBe('a');
|
||||
expect(next.round).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//
|
||||
// RED now: current code has two lists (sort on display, frozen turnOrderIds),
|
||||
// reorder throws on cross-init. Refactor (1-list model) greens these.
|
||||
//
|
||||
// New API: mutating funcs are async, take ctx last, write encounter + log
|
||||
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
|
||||
// No-ops (reorder blocked) return the same enc ref.
|
||||
|
||||
'use strict';
|
||||
|
||||
@@ -15,6 +19,7 @@ const {
|
||||
toggleParticipantActive, togglePause, applyHpChange,
|
||||
reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
@@ -24,104 +29,97 @@ function enc(ps, extra = {}) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
// walk one full rotation from current, collect active ids in list order
|
||||
function walkRotation(e) {
|
||||
if (!e.isStarted || e.isPaused || !e.currentTurnParticipantId) return [];
|
||||
let cur = e;
|
||||
const start = cur.currentTurnParticipantId;
|
||||
const seen = [];
|
||||
for (let i = 0; i < (cur.turnOrderIds || []).length + 1; i++) {
|
||||
const curP = (cur.participants || []).find(p => p.id === cur.currentTurnParticipantId);
|
||||
if (curP && curP.isActive) seen.push(cur.currentTurnParticipantId);
|
||||
const nxt = nextTurn(cur);
|
||||
cur = apply(cur, nxt);
|
||||
if (cur.currentTurnParticipantId === start) break;
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => {
|
||||
test('startEncounter: list = sorted-active participants order', () => {
|
||||
test('startEncounter: list = sorted-active participants order', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',3),p('b',10),p('c',5)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = await startEncounter(e, ctx);
|
||||
expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', () => {
|
||||
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = await startEncounter(e, ctx);
|
||||
// 1-list: inactive b stays in slot, nextTurn skips it
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start
|
||||
});
|
||||
|
||||
test('addParticipant mid-encounter: inserted by init, list synced', () => {
|
||||
test('addParticipant mid-encounter: inserted by init, list synced', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // [a,c]
|
||||
e = apply(e, addParticipant(e, p('b',7))); // insert between a,c
|
||||
e = await startEncounter(e, ctx); // [a,c]
|
||||
e = await addParticipant(e, p('b',7), ctx); // insert between a,c
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
test('addParticipant: list === participants.map(id) after add', () => {
|
||||
test('addParticipant: list === participants.map(id) after add', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, addParticipant(e, p('b',5)));
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await addParticipant(e, p('b',5), ctx);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
test('removeParticipant: list synced, order preserved', () => {
|
||||
test('removeParticipant: list synced, order preserved', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, removeParticipant(e, 'b'));
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await removeParticipant(e, 'b', ctx);
|
||||
expect(e.turnOrderIds).toEqual(['a','c']);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
test('reorder cross-init: blocked (no-op)', () => {
|
||||
test('reorder cross-init: blocked (no-op)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // [a,b,c]
|
||||
const r = reorderParticipants(e, 'c', 'a'); // cross-init
|
||||
expect(r.patch).toBeNull();
|
||||
e = await startEncounter(e, ctx); // [a,b,c]
|
||||
const newEnc = await reorderParticipants(e, 'c', 'a', ctx); // cross-init
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
|
||||
});
|
||||
|
||||
test('reorder same-init: within upcoming side of pointer follows new order', () => {
|
||||
test('reorder same-init: within upcoming side of pointer follows new order', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie
|
||||
e = apply(e, startEncounter(e)); // [a,b,c,d], cur=a (idx0)
|
||||
e = await startEncounter(e, ctx); // [a,b,c,d], cur=a (idx0)
|
||||
// Reorder c before b (both upcoming idx1,2). Within same side = allowed.
|
||||
e = apply(e, reorderParticipants(e, 'c', 'b')); // [a,c,b,d], cur=a
|
||||
e = await reorderParticipants(e, 'c', 'b', ctx); // [a,c,b,d], cur=a
|
||||
expect(e.turnOrderIds).toEqual(['a','c','b','d']);
|
||||
// walk: a current, next c, next b, next d, wrap a
|
||||
e = apply(e, nextTurn(e));
|
||||
e = await nextTurn(e, ctx);
|
||||
expect(e.currentTurnParticipantId).toBe('c');
|
||||
});
|
||||
|
||||
test('toggle inactive: list unchanged (stays in rotation slot)', () => {
|
||||
test('toggle inactive: list unchanged (stays in rotation slot)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // [a,b,c]
|
||||
e = apply(e, toggleParticipantActive(e, 'b')); // b off
|
||||
e = await startEncounter(e, ctx); // [a,b,c]
|
||||
e = await toggleParticipantActive(e, 'b', ctx); // b off
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
|
||||
});
|
||||
|
||||
test('toggle inactive: nextTurn skips b, visits a→c', () => {
|
||||
test('toggle inactive: nextTurn skips b, visits a→c', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // cur=a
|
||||
e = apply(e, toggleParticipantActive(e, 'b')); // b inactive
|
||||
e = apply(e, nextTurn(e)); // skip b → c
|
||||
e = await startEncounter(e, ctx); // cur=a
|
||||
e = await toggleParticipantActive(e, 'b', ctx); // b inactive
|
||||
e = await nextTurn(e, ctx); // skip b → c
|
||||
expect(e.currentTurnParticipantId).toBe('c');
|
||||
});
|
||||
|
||||
test('hp death + revive: list unchanged', () => {
|
||||
test('hp death + revive: list unchanged', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, applyHpChange(e, 'b', 'damage', 100)); // b dies
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dies
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||
e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive
|
||||
e = await applyHpChange(e, 'b', 'heal', 50, ctx); // b revive
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
+153
-108
@@ -1,203 +1,248 @@
|
||||
// Logging contract: every mutating combat op returns { patch, log } where
|
||||
// log = { message: string, undo: object|null }. No-op (no state change)
|
||||
// returns { patch: null, log: null }. Display lifecycle ops return { patch }
|
||||
// only (no log field expected).
|
||||
// Logging contract: every mutating combat op writes a log entry to ctx.logPath
|
||||
// via ctx.storage.addDoc. No-op (no state change) performs NO writes and
|
||||
// returns the same encounter reference. Display lifecycle ops write the display
|
||||
// doc, no combat log.
|
||||
//
|
||||
// logAction handler does:
|
||||
// if (log) logAction(log.message, ctx, { updates: log.undo })
|
||||
// → null log = skipped = gap in audit trail.
|
||||
// Contract = every combat mutation produces a log entry.
|
||||
// New shape: mutating funcs are async, take ctx, write encounter (updateDoc) +
|
||||
// log (addDoc) internally, return newEnc. Logs are read back via storage.logs().
|
||||
//
|
||||
// Contract = every combat mutation MUST produce a log entry.
|
||||
// undo payloads now live on the written log entry's `undo_payload.updates`.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const {
|
||||
makeParticipant, buildMonsterParticipant,
|
||||
makeParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
|
||||
reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
function p(id, init) {
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 100, currentHp: 100 });
|
||||
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||
}
|
||||
function enc(ps, extra = {}) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
// Helper: assert a result is a logged mutation (patch + valid log).
|
||||
function expectLogged(r) {
|
||||
expect(r.patch).not.toBeNull();
|
||||
expect(r.log).not.toBeNull();
|
||||
expect(typeof r.log).toBe('object');
|
||||
expect(typeof r.log.message).toBe('string');
|
||||
expect(r.log.message.length).toBeGreaterThan(0);
|
||||
}
|
||||
function expectNoOp(r) {
|
||||
expect(r.patch).toBeNull();
|
||||
expect(r.log).toBeNull();
|
||||
// Assert last written log entry is a well-formed mutation log.
|
||||
function expectLastLogged(storage) {
|
||||
const logs = storage.logs();
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
const last = logs[logs.length - 1];
|
||||
expect(typeof last.message).toBe('string');
|
||||
expect(last.message.length).toBeGreaterThan(0);
|
||||
return last;
|
||||
}
|
||||
|
||||
describe('Logging contract: mutating ops', () => {
|
||||
let e;
|
||||
let storage, ctx;
|
||||
beforeEach(() => {
|
||||
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
({ storage, ctx } = mockCtx());
|
||||
});
|
||||
|
||||
test('startEncounter logs', () => {
|
||||
const r = startEncounter(e);
|
||||
expectLogged(r);
|
||||
test('startEncounter logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await startEncounter(e, ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('nextTurn logs', () => {
|
||||
const started = apply(e, startEncounter(e));
|
||||
const r = nextTurn(started);
|
||||
expectLogged(r);
|
||||
test('nextTurn logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
const started = await startEncounter(e, ctx); // 1 log
|
||||
await nextTurn(started, ctx); // 2 logs
|
||||
expect(storage.logs()).toHaveLength(2);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('togglePause logs', () => {
|
||||
const r = togglePause(apply(e, startEncounter(e)));
|
||||
expectLogged(r);
|
||||
test('togglePause does NOT log (lifecycle excluded from undo)', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
const started = await startEncounter(e, ctx);
|
||||
await togglePause(started, ctx);
|
||||
expect(storage.logs()).toHaveLength(1); // start only
|
||||
});
|
||||
|
||||
test('addParticipant logs', () => {
|
||||
const r = addParticipant(e, p('d', 5));
|
||||
expectLogged(r);
|
||||
test('addParticipant logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await addParticipant(e, p('d', 5), ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('removeParticipant logs', () => {
|
||||
const r = removeParticipant(e, 'b');
|
||||
expectLogged(r);
|
||||
test('removeParticipant logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await removeParticipant(e, 'b', ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('toggleParticipantActive logs', () => {
|
||||
const r = toggleParticipantActive(e, 'b');
|
||||
expectLogged(r);
|
||||
test('toggleParticipantActive logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await toggleParticipantActive(e, 'b', ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('applyHpChange (damage) logs', () => {
|
||||
const r = applyHpChange(e, 'b', 'damage', 10);
|
||||
expectLogged(r);
|
||||
test('applyHpChange (damage) logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await applyHpChange(e, 'b', 'damage', 10, ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('applyHpChange (heal) logs', () => {
|
||||
const r = applyHpChange(e, 'b', 'heal', 5);
|
||||
expectLogged(r);
|
||||
test('applyHpChange (heal) logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await applyHpChange(e, 'b', 'heal', 5, ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('deathSave logs', () => {
|
||||
const started = apply(e, startEncounter(e));
|
||||
const dying = apply(started, applyHpChange(started, 'b', 'damage', 100));
|
||||
const r = deathSave(dying, 'b', 'fail', 1);
|
||||
expectLogged(r);
|
||||
test('deathSave logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7, { type: 'character' }), p('c', 3)]);
|
||||
const started = await startEncounter(e, ctx); // 1 log
|
||||
const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs
|
||||
const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs
|
||||
expect(r.status).toBe('dying');
|
||||
expect(storage.logs()).toHaveLength(3);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('toggleCondition logs', () => {
|
||||
const r = toggleCondition(e, 'b', 'poisoned');
|
||||
expectLogged(r);
|
||||
test('toggleCondition logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await toggleCondition(e, 'b', 'poisoned', ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('reorderParticipants logs (BUG-7)', () => {
|
||||
// same-init tie (both 10) for valid reorder
|
||||
test('reorderParticipants logs (BUG-7)', async () => {
|
||||
// same-init tie (both 10) for valid reorder (unstarted: no pointer check)
|
||||
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
|
||||
const r = reorderParticipants(e2, 'x', 'a');
|
||||
expectLogged(r);
|
||||
await reorderParticipants(e2, 'x', 'a', ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('endEncounter logs', () => {
|
||||
const started = apply(e, startEncounter(e));
|
||||
const r = endEncounter(started);
|
||||
expectLogged(r);
|
||||
test('endEncounter logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
const started = await startEncounter(e, ctx);
|
||||
await endEncounter(started, ctx);
|
||||
expect(storage.logs()).toHaveLength(2);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logging contract: no-ops', () => {
|
||||
let e;
|
||||
let storage, ctx;
|
||||
beforeEach(() => {
|
||||
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
({ storage, ctx } = mockCtx());
|
||||
});
|
||||
|
||||
test('reorder same-id = no-op', () => {
|
||||
expectNoOp(reorderParticipants(e, 'a', 'a'));
|
||||
test('reorder same-id = no-op', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(storage.logs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('reorder cross-init = no-op', () => {
|
||||
expectNoOp(reorderParticipants(e, 'a', 'b'));
|
||||
test('reorder cross-init = no-op', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(storage.logs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logging undo payloads', () => {
|
||||
let e;
|
||||
let storage, ctx;
|
||||
beforeEach(() => {
|
||||
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
({ storage, ctx } = mockCtx());
|
||||
});
|
||||
|
||||
test('startEncounter undo restores pre-combat state', () => {
|
||||
const r = startEncounter(e);
|
||||
expect(r.log.undo).toBeDefined();
|
||||
expect(r.log.undo.isStarted).toBe(false);
|
||||
test('startEncounter undo restores pre-combat state', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
await startEncounter(e, ctx);
|
||||
const log = expectLastLogged(storage);
|
||||
expect(log.undo).toBeDefined();
|
||||
expect(log.undo.isStarted).toBe(false);
|
||||
});
|
||||
|
||||
test('endEncounter undo restores combat state', () => {
|
||||
const started = apply(e, startEncounter(e));
|
||||
const r = endEncounter(started);
|
||||
expect(r.log.undo).toBeDefined();
|
||||
expect(r.log.undo.isStarted).toBe(true);
|
||||
test('endEncounter undo restores combat state', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
const started = await startEncounter(e, ctx);
|
||||
await endEncounter(started, ctx);
|
||||
const log = expectLastLogged(storage);
|
||||
expect(log.undo).toBeDefined();
|
||||
expect(log.undo.isStarted).toBe(true);
|
||||
});
|
||||
|
||||
test('applyHpChange undo restores prior hp', () => {
|
||||
const r = applyHpChange(e, 'b', 'damage', 10);
|
||||
expect(r.log.undo).toBeDefined();
|
||||
expect(r.log.undo.participants).toBeDefined();
|
||||
test('applyHpChange undo restores prior hp', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
const newEnc = await applyHpChange(e, 'b', 'damage', 10, ctx);
|
||||
const log = expectLastLogged(storage);
|
||||
expect(log.undo).toBeDefined();
|
||||
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||
expect(restored.participants.find(x => x.id === 'b').currentHp).toBe(100);
|
||||
});
|
||||
|
||||
test('reorder undo restores prior order (BUG-7)', () => {
|
||||
test('reorder undo restores prior order (BUG-7)', async () => {
|
||||
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
|
||||
const orig = e2.participants.map(p => p.id);
|
||||
const r = reorderParticipants(e2, 'x', 'a');
|
||||
expect(r.log.undo).toBeDefined();
|
||||
const restored = { ...e2, ...r.log.undo };
|
||||
const newEnc = await reorderParticipants(e2, 'x', 'a', ctx);
|
||||
const log = expectLastLogged(storage);
|
||||
expect(log.undo).toBeDefined();
|
||||
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logging: addParticipants + updateParticipant', () => {
|
||||
let e;
|
||||
let storage, ctx;
|
||||
beforeEach(() => {
|
||||
e = enc([p('a', 10), p('b', 7)]);
|
||||
({ storage, ctx } = mockCtx());
|
||||
});
|
||||
|
||||
test('addParticipants logs', () => {
|
||||
const r = addParticipants(e, [p('c', 3)]);
|
||||
expectLogged(r);
|
||||
test('addParticipants logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7)]);
|
||||
await addParticipants(e, [p('c', 3)], ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('updateParticipant (same slot) logs', () => {
|
||||
const r = updateParticipant(e, 'b', { name: 'B' });
|
||||
expectLogged(r);
|
||||
test('updateParticipant (same slot) logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7)]);
|
||||
await updateParticipant(e, 'b', { name: 'B' }, ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('updateParticipant (init change) logs', () => {
|
||||
const r = updateParticipant(e, 'b', { initiative: 5 });
|
||||
expectLogged(r);
|
||||
test('updateParticipant (init change) logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7)]);
|
||||
await updateParticipant(e, 'b', { initiative: 5 }, ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
test('addParticipants undo restores prior list', () => {
|
||||
test('addParticipants undo restores prior list', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7)]);
|
||||
const orig = e.participants.map(p => p.id);
|
||||
const r = addParticipants(e, [p('c', 3)]);
|
||||
const restored = { ...e, ...r.log.undo };
|
||||
const newEnc = await addParticipants(e, [p('c', 3)], ctx);
|
||||
const log = expectLastLogged(storage);
|
||||
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||
});
|
||||
|
||||
test('updateParticipant undo restores prior participant', () => {
|
||||
test('updateParticipant undo restores prior participant', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7)]);
|
||||
const orig = e.participants.map(p => p.id);
|
||||
const r = updateParticipant(e, 'b', { name: 'B' });
|
||||
const restored = { ...e, ...r.log.undo };
|
||||
const newEnc = await updateParticipant(e, 'b', { name: 'B' }, ctx);
|
||||
const log = expectLastLogged(storage);
|
||||
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`).
|
||||
// Validates real bug reachable via normal UI flow (DM adds monster while paused,
|
||||
// resumes).
|
||||
//
|
||||
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
@@ -28,24 +31,25 @@ function enc(ps) {
|
||||
}
|
||||
|
||||
describe('addParticipant + pause/resume rotation corruption', () => {
|
||||
test('add fresh participant while paused, resume, rotation completes full cycle', () => {
|
||||
test('add fresh participant while paused, resume, rotation completes full cycle', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
e = { ...e, ...togglePause(e).patch }; // pause
|
||||
e = await nextTurn(e, ctx); // current=b
|
||||
e = await togglePause(e, ctx); // pause
|
||||
|
||||
// add fresh participant x (initiative 25, would sort first)
|
||||
const x = p('x', 25);
|
||||
e = { ...e, ...addParticipant(e, x).patch };
|
||||
e = { ...e, ...togglePause(e).patch }; // resume (rebuilds order)
|
||||
e = await addParticipant(e, x, ctx);
|
||||
e = await togglePause(e, ctx); // resume (rebuilds order)
|
||||
|
||||
// after resume, complete one full round: visit each active participant once
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
@@ -53,24 +57,25 @@ describe('addParticipant + pause/resume rotation corruption', () => {
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
|
||||
test('multiple adds while paused, resume, rotation visits all', () => {
|
||||
test('multiple adds while paused, resume, rotation visits all', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
e = { ...e, ...togglePause(e).patch }; // pause
|
||||
e = await nextTurn(e, ctx); // current=b
|
||||
e = await togglePause(e, ctx); // pause
|
||||
|
||||
// add 3 fresh participants
|
||||
for (const id of ['x', 'y', 'z']) {
|
||||
const np = p(id, 5 + Math.floor(Math.random() * 30));
|
||||
e = { ...e, ...addParticipant(e, np).patch };
|
||||
e = await addParticipant(e, np, ctx);
|
||||
}
|
||||
e = { ...e, ...togglePause(e).patch }; // resume
|
||||
e = await togglePause(e, ctx); // resume
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
@@ -78,20 +83,21 @@ describe('addParticipant + pause/resume rotation corruption', () => {
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
|
||||
test('add while running, then pause+resume, rotation stays valid', () => {
|
||||
test('add while running, then pause+resume, rotation stays valid', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
e = await nextTurn(e, ctx); // current=b
|
||||
const x = p('x', 25);
|
||||
e = { ...e, ...addParticipant(e, x).patch }; // add while running
|
||||
e = { ...e, ...togglePause(e).patch }; // pause
|
||||
e = { ...e, ...togglePause(e).patch }; // resume
|
||||
e = await addParticipant(e, x, ctx); // add while running
|
||||
e = await togglePause(e, ctx); // pause
|
||||
e = await togglePause(e, ctx); // resume
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// removeParticipant + computeTurnOrderAfterRemoval edge cases.
|
||||
//
|
||||
// New API: mutating funcs are async, take ctx last, write encounter + log
|
||||
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
@@ -13,51 +17,56 @@ function enc(ps) {
|
||||
}
|
||||
|
||||
describe('removeParticipant turn-order edges', () => {
|
||||
test('removing current picks next active as current', () => {
|
||||
test('removing current picks next active as current', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = { ...e, ...removeParticipant(e, 'a').patch }; // a was current
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await removeParticipant(e, 'a', ctx); // a was current
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
|
||||
test('removing last in order wraps current to first', () => {
|
||||
test('removing last in order wraps current to first', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = { ...e, ...nextTurn(e).patch }; // b
|
||||
e = { ...e, ...nextTurn(e).patch }; // c (current)
|
||||
e = { ...e, ...removeParticipant(e, 'c').patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await nextTurn(e, ctx); // b
|
||||
e = await nextTurn(e, ctx); // c (current)
|
||||
e = await removeParticipant(e, 'c', ctx);
|
||||
expect(e.currentTurnParticipantId).toBe('a');
|
||||
});
|
||||
|
||||
test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', () => {
|
||||
test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||
e = { ...e, ...startEncounter(e).patch }; // [a,b,c], cur=a
|
||||
e = await startEncounter(e, ctx); // [a,b,c], cur=a
|
||||
// deactivate b + c (stay in slot, inactive)
|
||||
e = { ...e, ...toggleParticipantActive(e, 'b').patch };
|
||||
e = { ...e, ...toggleParticipantActive(e, 'c').patch };
|
||||
e = await toggleParticipantActive(e, 'b', ctx);
|
||||
e = await toggleParticipantActive(e, 'c', ctx);
|
||||
// remove current a
|
||||
e = { ...e, ...removeParticipant(e, 'a').patch };
|
||||
e = await removeParticipant(e, 'a', ctx);
|
||||
// 1-list: turnOrderIds=[b,c], no active → current null, isStarted stays true
|
||||
expect(e.turnOrderIds).toEqual(['b', 'c']);
|
||||
expect(e.currentTurnParticipantId).toBeNull();
|
||||
// isStarted still true but no turn → nextTurn throws (stale state)
|
||||
});
|
||||
|
||||
test('removing non-current keeps currentTurn', () => {
|
||||
test('removing non-current keeps currentTurn', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = { ...e, ...removeParticipant(e, 'b').patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await removeParticipant(e, 'b', ctx);
|
||||
expect(e.currentTurnParticipantId).toBe('a');
|
||||
expect(e.turnOrderIds).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
test('removing current that is dead (HP=0) - BUG-3 overlap', () => {
|
||||
test('removing current that is dead (HP=0) - BUG-3 overlap', async () => {
|
||||
// Dead participant removed mid-combat. Desired (M4): they STAY in order.
|
||||
// removeParticipant is explicit DM action, distinct from auto-skip.
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; // b dead
|
||||
e = { ...e, ...removeParticipant(e, 'b').patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dead
|
||||
e = await removeParticipant(e, 'b', ctx);
|
||||
expect(e.turnOrderIds).not.toContain('b');
|
||||
expect(e.participants.find(x => x.id === 'b')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// Characterization for reorderParticipants correct usage.
|
||||
// replay-combat.js calls it with wrong signature (swallowed by try/catch),
|
||||
// so real behavior untested. Lock what it actually does.
|
||||
//
|
||||
// New API: reorderParticipants is async, takes ctx last, writes encounter +
|
||||
// log internally, returns newEnc. A blocked move (cross-init / same id /
|
||||
// cross-pointer) returns the SAME enc ref (no write). Missing id rejects.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
@@ -18,37 +23,65 @@ function enc(ps) {
|
||||
}
|
||||
|
||||
describe('reorderParticipants', () => {
|
||||
test('drag before target (1-list model, pre-combat)', () => {
|
||||
test('drag upward inserts before target (1-list model, pre-combat)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
|
||||
let e = enc(ps); // pre-combat, no pointer
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
// drag c before b: remove c → [a,b], insert before b → [a,c,b]
|
||||
expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
|
||||
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||
// drag c upward onto b: insert before b → [a,c,b]
|
||||
expect(newEnc.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
test('cross-init drag blocked (no-op)', () => {
|
||||
test('drag downward inserts after target (1-list model, pre-combat)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 20), p('c', 10)]; // a,b tie
|
||||
let e = enc(ps); // pre-combat, no pointer
|
||||
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||
// drag a downward onto b: insert after b → [b,a,c]
|
||||
expect(newEnc.participants.map(p => p.id)).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('cross-init drag blocked (no-op)', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 20)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch }; // [b,a]
|
||||
const r = reorderParticipants(e, 'a', 'b');
|
||||
expect(r.patch).toBeNull();
|
||||
e = await startEncounter(e, ctx); // [b,a]
|
||||
const before = storage.calls.filter(c => c.fn === 'updateDoc').length;
|
||||
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||
expect(newEnc).toBe(e); // same ref = no write
|
||||
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(before); // no new write
|
||||
});
|
||||
|
||||
test('throws if id not found', () => {
|
||||
test('throws if id not found', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 20)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
expect(() => reorderParticipants(e, 'a', 'zzz')).toThrow();
|
||||
e = await startEncounter(e, ctx);
|
||||
await expect(reorderParticipants(e, 'a', 'zzz', ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => {
|
||||
test('paused combat allows reorder across current-turn pointer', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 20), p('c', 20), p('d', 20)];
|
||||
let e = await startEncounter(enc(ps), ctx); // current=a
|
||||
e = { ...e, isPaused: true };
|
||||
|
||||
const newEnc = await reorderParticipants(e, 'd', 'a', ctx);
|
||||
|
||||
expect(newEnc).not.toBe(e);
|
||||
expect(newEnc.participants.map(p => p.id)).toEqual(['d', 'a', 'b', 'c']);
|
||||
expect(newEnc.turnOrderIds).toEqual(['d', 'a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch }; // started, but reorder pre-pointer advance
|
||||
e = await startEncounter(e, ctx); // started, but reorder pre-pointer advance
|
||||
// startEncounter sets current=b (idx0). reorder c before b crosses pointer.
|
||||
// Use pre-combat to test syncTurnOrder.
|
||||
let pre = enc(ps);
|
||||
pre = { ...pre, ...reorderParticipants(pre, 'c', 'b').patch };
|
||||
pre = await reorderParticipants(pre, 'c', 'b', ctx);
|
||||
expect(pre.turnOrderIds).toEqual(['a','c','b']);
|
||||
expect(pre.turnOrderIds).toEqual(pre.participants.map(p => p.id));
|
||||
});
|
||||
@@ -56,10 +89,11 @@ describe('reorderParticipants', () => {
|
||||
// BUG-6 candidate: reorder should affect turnOrderIds so mid-combat
|
||||
// drag-drop changes who goes next within same-initiative tie.
|
||||
// Currently RED (turnOrderIds not in patch).
|
||||
test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', () => {
|
||||
test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||
let e = enc(ps); // pre-combat, no pointer
|
||||
e = { ...e, ...reorderParticipants(e, 'c', 'b').patch };
|
||||
e = await reorderParticipants(e, 'c', 'b', ctx);
|
||||
expect(e.turnOrderIds).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter
|
||||
// before any coverage action). nextTurn has core bug, not just coverage-path issue.
|
||||
//
|
||||
// This test is RED until nextTurn fixed.
|
||||
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const { startEncounter, nextTurn, makeParticipant } = shared;
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
@@ -24,17 +25,18 @@ function enc(ps) {
|
||||
}
|
||||
|
||||
describe('round rotation integrity', () => {
|
||||
test('3 participants: one full round visits each exactly once', () => {
|
||||
test('3 participants: one full round visits each exactly once', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
|
||||
// advance (len-1) turns: visits remaining participants, round NOT yet wrapped.
|
||||
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
|
||||
@@ -44,18 +46,19 @@ describe('round rotation integrity', () => {
|
||||
expect(visited.length).toBe(startOrder.length);
|
||||
});
|
||||
|
||||
test('8 participants (replay shape): one full round visits each exactly once', () => {
|
||||
test('8 participants (replay shape): one full round visits each exactly once', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [
|
||||
p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11),
|
||||
p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10),
|
||||
];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
|
||||
@@ -65,10 +68,11 @@ describe('round rotation integrity', () => {
|
||||
expect(visited.length).toBe(startOrder.length);
|
||||
});
|
||||
|
||||
test('multiple rounds: each round visits each participant exactly once', () => {
|
||||
test('multiple rounds: each round visits each participant exactly once', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const expectedRound = e.round;
|
||||
@@ -76,7 +80,7 @@ describe('round rotation integrity', () => {
|
||||
// capture exactly one full round (current + len-1 advances), no wrap yet.
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
@@ -88,63 +92,65 @@ describe('round rotation integrity', () => {
|
||||
describe('round rotation with mid-round state changes', () => {
|
||||
const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared;
|
||||
|
||||
test('toggle a participant inactive mid-round, others still each visited once', () => {
|
||||
test('toggle a participant inactive mid-round, others still each visited once', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
|
||||
// now mark 'a' inactive (already took its turn)
|
||||
e = { ...e, ...toggleParticipantActive(e, 'a').patch };
|
||||
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||
e = await toggleParticipantActive(e, 'a', ctx);
|
||||
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
|
||||
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
|
||||
// round should wrap, but 'a' inactive so only b,c,d visited
|
||||
const visitedActive = visited.filter(id => id !== 'a');
|
||||
const uniq = new Set(visitedActive);
|
||||
expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once
|
||||
});
|
||||
|
||||
test('reactivate inactive participant mid-round, it gets a turn this round', () => {
|
||||
test('reactivate inactive participant mid-round, it gets a turn this round', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
// start with 'c' inactive
|
||||
e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
// 1-list: c stays in slot (inactive), skipped by nextTurn
|
||||
expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first
|
||||
|
||||
// advance one turn, then reactivate c
|
||||
e = { ...e, ...nextTurn(e).patch }; // b
|
||||
e = { ...e, ...toggleParticipantActive(e, 'c').patch };
|
||||
e = await nextTurn(e, ctx); // b
|
||||
e = await toggleParticipantActive(e, 'c', ctx);
|
||||
|
||||
// continue rotation - c should now be reachable
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
expect(visited).toContain('c');
|
||||
});
|
||||
|
||||
test('addParticipant mid-round: new participant gets turn this round or next', () => {
|
||||
test('addParticipant mid-round: new participant gets turn this round or next', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // advance one
|
||||
e = await nextTurn(e, ctx); // advance one
|
||||
// add new participant
|
||||
const newP = p('x', 25);
|
||||
e = { ...e, ...addParticipant(e, newP).patch };
|
||||
e = await addParticipant(e, newP, ctx);
|
||||
|
||||
// finish round - original 3 should still each get exactly one turn
|
||||
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||
while (e.round === 1) {
|
||||
const r = nextTurn(e);
|
||||
e = { ...e, ...r.patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
if (visited.length > 20) break; // safety
|
||||
}
|
||||
@@ -153,23 +159,23 @@ describe('round rotation with mid-round state changes', () => {
|
||||
expect(uniq.size).toBe(3);
|
||||
});
|
||||
|
||||
test('reorderParticipants mid-round keeps rotation valid', () => {
|
||||
test('reorderParticipants mid-round keeps rotation valid', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15)
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
// reorder: swap b,c (same initiative)
|
||||
e = { ...e, ...reorderParticipants(e, 'b', 'c').patch };
|
||||
e = await reorderParticipants(e, 'b', 'c', ctx);
|
||||
|
||||
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = await nextTurn(e, ctx);
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
//
|
||||
// Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume).
|
||||
// If this goes RED, turn order rotation is skipping participants again.
|
||||
//
|
||||
// New API: mutating funcs are async, take ctx last, write encounter + log
|
||||
// internally, return newEnc. setup(ctx) returns the started encounter;
|
||||
// loop bodies await each mutating call.
|
||||
|
||||
'use strict';
|
||||
|
||||
@@ -13,14 +17,14 @@ const {
|
||||
startEncounter, nextTurn, togglePause, addParticipant, removeParticipant,
|
||||
toggleParticipantActive,
|
||||
} = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
const apply = (e, r) => (r && r.patch) ? { ...e, ...r.patch } : e;
|
||||
const nm = (enc) => (id) => {
|
||||
const f = enc.participants.find(p => p.id === id);
|
||||
return f ? f.name : id;
|
||||
};
|
||||
|
||||
function setup() {
|
||||
function setup(ctx) {
|
||||
const ps = [
|
||||
buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant,
|
||||
buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant,
|
||||
@@ -29,20 +33,21 @@ function setup() {
|
||||
buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant,
|
||||
buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant,
|
||||
buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant,
|
||||
buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant,
|
||||
buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, asNpc: true }).participant,
|
||||
];
|
||||
let e = {
|
||||
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
return apply(e, startEncounter(e));
|
||||
return startEncounter(e, ctx); // async → Promise<newEnc>
|
||||
}
|
||||
|
||||
describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||
jest.setTimeout(15000);
|
||||
|
||||
test('pure nextTurn: 0 skips across 100 rounds', () => {
|
||||
let e = setup();
|
||||
test('pure nextTurn: 0 skips across 100 rounds', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = await setup(ctx);
|
||||
let totalSkips = 0;
|
||||
for (let roundN = 1; roundN <= 100; roundN++) {
|
||||
const startRound = e.round;
|
||||
@@ -52,7 +57,7 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||
let guard = 0;
|
||||
const cap = e.participants.length + 1;
|
||||
while (e.round === startRound && guard < cap) {
|
||||
e = apply(e, nextTurn(e));
|
||||
e = await nextTurn(e, ctx);
|
||||
if (e.round === startRound) acted.add(e.currentTurnParticipantId);
|
||||
guard++;
|
||||
}
|
||||
@@ -65,8 +70,9 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||
expect(totalSkips).toBe(0);
|
||||
});
|
||||
|
||||
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', () => {
|
||||
let e = setup();
|
||||
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = await setup(ctx);
|
||||
const N = nm(e);
|
||||
let curRound = null;
|
||||
let activeAtRoundStart = new Set();
|
||||
@@ -85,10 +91,10 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||
const MAX_TURNS = 2000;
|
||||
while (turns < MAX_TURNS && e.isStarted) {
|
||||
turns++;
|
||||
if (e.isPaused) e = apply(e, togglePause(e));
|
||||
if (turns % 7 === 0 && !e.isPaused) { e = apply(e, togglePause(e)); continue; }
|
||||
if (e.isPaused) e = await togglePause(e, ctx);
|
||||
if (turns % 7 === 0 && !e.isPaused) { e = await togglePause(e, ctx); continue; }
|
||||
const prevRound = e.round;
|
||||
e = apply(e, nextTurn(e));
|
||||
e = await nextTurn(e, ctx);
|
||||
if (e.round !== prevRound) {
|
||||
const skipped = [...activeAtRoundStart].filter(id => {
|
||||
const p = e.participants.find(x => x.id === id);
|
||||
@@ -102,18 +108,18 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||
if (turns % 9 === 0 && added < 8) {
|
||||
const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant;
|
||||
b.id = `reinforce${added + 1}`;
|
||||
e = apply(e, addParticipant(e, b)); added++;
|
||||
e = await addParticipant(e, b, ctx); added++;
|
||||
}
|
||||
if (turns % 13 === 0) {
|
||||
const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId);
|
||||
if (cand.length) e = apply(e, removeParticipant(e, cand[0].id));
|
||||
if (cand.length) e = await removeParticipant(e, cand[0].id, ctx);
|
||||
}
|
||||
if (turns % 17 === 0) {
|
||||
const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId);
|
||||
if (cand.length) {
|
||||
const t = cand[0];
|
||||
e = apply(e, toggleParticipantActive(e, t.id));
|
||||
e = apply(e, toggleParticipantActive(e, t.id));
|
||||
e = await toggleParticipantActive(e, t.id, ctx);
|
||||
e = await toggleParticipantActive(e, t.id, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
// "Tie-break = original add order. Later additions slot AFTER existing
|
||||
// same-init participants. Stable insertion."
|
||||
//
|
||||
// Current code uses sortParticipantsByInitiative (stable sort, tie-break =
|
||||
// original array index). That is NOT stable insertion: it re-sorts the whole
|
||||
// list, destroying drag order when a drag moved a same-init pair.
|
||||
// startEncounter re-sorts once to freeze the list; after that add/update use
|
||||
// stable slot insertion (slotIndexForInit), never wholesale re-sort — so drag
|
||||
// tie order survives.
|
||||
//
|
||||
// RED now: drag tie order survives neither add nor edit.
|
||||
// New API: addParticipant / updateParticipant / reorderParticipants are async,
|
||||
// take ctx, return newEnc.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant,
|
||||
startEncounter, addParticipant, updateParticipant, reorderParticipants,
|
||||
} = shared;
|
||||
} = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
@@ -28,47 +29,50 @@ function enc(ps, extra = {}) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => {
|
||||
test('addParticipant preserves existing drag tie order', () => {
|
||||
test('addParticipant preserves existing drag tie order', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// Two same-init participants. DM drags b BEFORE a (tie override).
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // drag b ahead of a → [b,a]
|
||||
e = await reorderParticipants(e, 'b', 'a', ctx); // drag b ahead of a → [b,a]
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||
|
||||
// Add unrelated participant (different init). Tie order [b,a] MUST survive.
|
||||
e = apply(e, addParticipant(e, p('c', 5)));
|
||||
e = await addParticipant(e, p('c', 5), ctx);
|
||||
const ids = e.participants.map(x => x.id);
|
||||
// c slots last (init 5 < 10). b,a tie order preserved.
|
||||
expect(ids).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('addParticipant with same init as dragged pair slots AFTER tie group', () => {
|
||||
test('addParticipant with same init as dragged pair slots AFTER tie group', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// DM drags b before a (both init 10). Then add d also init 10.
|
||||
// d must slot AFTER existing same-init pair (stable insertion), not re-sort.
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
|
||||
e = apply(e, addParticipant(e, p('d', 10))); // init 10, after tie group
|
||||
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
|
||||
e = await addParticipant(e, p('d', 10), ctx); // init 10, after tie group
|
||||
const ids = e.participants.map(x => x.id);
|
||||
expect(ids).toEqual(['b', 'a', 'd']);
|
||||
});
|
||||
|
||||
test('updateParticipant preserves drag tie order on unrelated field edit', () => {
|
||||
test('updateParticipant preserves drag tie order on unrelated field edit', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
|
||||
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
|
||||
// Edit a's name (not initiative). Tie order MUST survive.
|
||||
e = apply(e, updateParticipant(e, 'a', { name: 'A2' }));
|
||||
e = await updateParticipant(e, 'a', { name: 'A2' }, ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
test('updateParticipant init change slots, preserves OTHER tie group order', () => {
|
||||
test('updateParticipant init change slots, preserves OTHER tie group order', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// Two tie groups: [a,b] init 10, [c,d] init 5. DM drags b before a.
|
||||
let e = enc([p('a', 10), p('b', 10), p('c', 5), p('d', 5)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c,d]
|
||||
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a,c,d]
|
||||
// Edit c's initiative to 10 — slots into top group. [c] order within its
|
||||
// own old group irrelevant (it leaves that group). But [b,a] tie MUST stay.
|
||||
e = apply(e, updateParticipant(e, 'c', { initiative: 10 }));
|
||||
e = await updateParticipant(e, 'c', { initiative: 10 }, ctx);
|
||||
const topTwo = e.participants.filter(x => x.initiative === 10).map(x => x.id);
|
||||
// b before a preserved. c joins the 10-group (exact slot less critical
|
||||
// than b,a order surviving).
|
||||
|
||||
+147
-70
@@ -1,7 +1,17 @@
|
||||
// Undo roundtrip: every op that returns log.undo must restore prior state.
|
||||
// Apply op → patch → apply undo → assert deepEqual original.
|
||||
|
||||
// Undo + redo roundtrip: every op that writes a log entry must restore prior
|
||||
// state on undo AND re-apply forward state on redo. Uses REAL mock storage
|
||||
// undo() (applies patch + flips undone flag), not just expandUndo output.
|
||||
//
|
||||
// Pattern per case:
|
||||
// before = snap(enc) // state before op
|
||||
// newEnc = await op(enc) // apply op (writes enc doc + log entry)
|
||||
// afterUndo = undoLast() // real undo via storage.undo
|
||||
// expect(afterUndo) === before
|
||||
// afterRedo = redoLast() // real redo via storage.undo({redo:true})
|
||||
// expect(afterRedo) === snap(newEnc)
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx, undoLast, redoLast } = require('./_helpers');
|
||||
const { expandUndo } = shared;
|
||||
const {
|
||||
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||
addParticipant, removeParticipant, toggleParticipantActive,
|
||||
@@ -21,110 +31,177 @@ function enc(ps) {
|
||||
}
|
||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||
|
||||
describe('undo roundtrip', () => {
|
||||
test('startEncounter undo restores pre-start', () => {
|
||||
describe('undo + redo roundtrip', () => {
|
||||
test('startEncounter', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const before = enc([p('a',10),p('b',20)]);
|
||||
const r = startEncounter(before);
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
// undo restores isStarted/isPaused/round/current/turnOrderIds.
|
||||
// participants[] may be reordered (1-list sort on start) — undo snapshot
|
||||
// captures turn-state fields, not participant order.
|
||||
const after = { ...before, ...r.patch, ...r.log.undo };
|
||||
expect(after.isStarted).toBe(before.isStarted);
|
||||
expect(after.isPaused).toBe(before.isPaused);
|
||||
expect(after.round).toBe(before.round);
|
||||
expect(after.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
|
||||
expect(after.turnOrderIds).toEqual(before.turnOrderIds);
|
||||
await storage.setDoc(ctx.encPath, before);
|
||||
const newEnc = await startEncounter(before, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
// participants[] order may differ (start sorts); check turn-state fields.
|
||||
expect(afterUndo.isStarted).toBe(before.isStarted);
|
||||
expect(afterUndo.isPaused).toBe(before.isPaused);
|
||||
expect(afterUndo.round).toBe(before.round);
|
||||
expect(afterUndo.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
|
||||
expect(afterUndo.turnOrderIds).toEqual(before.turnOrderIds);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(afterRedo.isStarted).toBe(newEnc.isStarted);
|
||||
expect(afterRedo.round).toBe(newEnc.round);
|
||||
expect(afterRedo.currentTurnParticipantId).toBe(newEnc.currentTurnParticipantId);
|
||||
expect(afterRedo.turnOrderIds).toEqual(newEnc.turnOrderIds);
|
||||
});
|
||||
|
||||
test('nextTurn undo restores prior currentTurn/round', () => {
|
||||
test('nextTurn', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const r = nextTurn(e);
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await nextTurn(e, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('togglePause undo restores prior paused state', () => {
|
||||
test('togglePause excluded from undo (no log entry)', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const r = togglePause(e);
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await togglePause(e, ctx);
|
||||
// state flips (isPaused), but no log written
|
||||
expect(newEnc.isPaused).toBe(true);
|
||||
expect(storage.logs()).toHaveLength(1); // start only
|
||||
});
|
||||
|
||||
test('applyHpChange undo restores prior participants', () => {
|
||||
test('applyHpChange damage restores HP on undo, re-applies on redo', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const r = applyHpChange(e, 'a', 'damage', 20);
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('toggleCondition undo restores prior participants', () => {
|
||||
test('applyHpChange heal restores HP on undo, re-applies on redo', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10,{maxHp:100,currentHp:50})]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('toggleCondition', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const r = toggleCondition(e, 'a', 'stunned');
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await toggleCondition(e, 'a', 'stunned', ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('toggleParticipantActive undo restores prior participants + turn order', () => {
|
||||
test('toggleParticipantActive restores participants + turn order, redo re-applies', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const r = toggleParticipantActive(e, 'b');
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await toggleParticipantActive(e, 'b', ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('addParticipant undo restores prior participants', () => {
|
||||
test('addParticipant', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 });
|
||||
const r = addParticipant(e, np);
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await addParticipant(e, np, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('removeParticipant undo restores prior participants + turn order', () => {
|
||||
test('removeParticipant restores prior participants + turn order, redo re-applies', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const r = removeParticipant(e, 'b');
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await removeParticipant(e, 'b', ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('endEncounter undo restores prior state', () => {
|
||||
test('endEncounter', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const r = endEncounter(e);
|
||||
expect(r.log.undo).toBeTruthy();
|
||||
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const newEnc = await endEncounter(e, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('reorderParticipants has no undo (log: null) — BUG candidate', () => {
|
||||
test('reorderParticipants (BUG-7 fixed)', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const ps = [p('a',10),p('b',20),p('c',20)]; // b,c tie
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
// Documents: reorderParticipants returns log: null. Cannot undo.
|
||||
// If undo expected here, this is BUG-7.
|
||||
expect(r.log).toBeNull();
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('updateParticipant', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await shared.updateParticipant(e, 'a', { initiative: 99 }, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
});
|
||||
|
||||
+666
-441
File diff suppressed because it is too large
Load Diff
+648
-295
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ export function doc(db, path, extra) {
|
||||
}
|
||||
export function collection(db, path) { return ref(path); }
|
||||
export function query(refOrColl, ...constraints) { return { ref: refOrColl, constraints }; }
|
||||
export function where(field, op, value) { return { __type: 'where', field, op, value }; }
|
||||
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||
export function limit(n) { return { __type: 'limit', n }; }
|
||||
|
||||
@@ -67,10 +68,18 @@ export async function getDoc(docRef) {
|
||||
}
|
||||
export async function getDocs(collRefOrQuery) {
|
||||
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||
const docs = MOCK_DB.collection(collPath);
|
||||
let docs = MOCK_DB.collection(collPath);
|
||||
const constraints = collRefOrQuery.constraints || [];
|
||||
docs = applyConstraints(docs, constraints);
|
||||
return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) };
|
||||
}
|
||||
|
||||
export async function getCountFromServer(collRefOrQuery) {
|
||||
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||
const docs = MOCK_DB.collection(collPath);
|
||||
return { data: () => ({ count: docs.length }) };
|
||||
}
|
||||
|
||||
// realtime — emit from mock DB, capture unsub
|
||||
export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
|
||||
@@ -112,6 +121,8 @@ function applyConstraints(docs, constraints) {
|
||||
});
|
||||
} else if (c.__type === 'limit') {
|
||||
out = out.slice(0, c.n);
|
||||
} else if (c.__type === 'where') {
|
||||
out = out.filter(d => d.data[c.field] === c.value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
+56
-2
@@ -6,8 +6,6 @@
|
||||
// const { runStorageContract } = require('./contract.test');
|
||||
// runStorageContract('memory', () => createMemoryStorage());
|
||||
|
||||
'use strict';
|
||||
|
||||
// Each impl factory returns a fresh storage instance (async-creatable is fine).
|
||||
// Interface every impl MUST provide:
|
||||
// getDoc(path) -> Promise<obj|null>
|
||||
@@ -17,6 +15,7 @@
|
||||
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
|
||||
// getCollection(path) -> Promise<arr> (immediate child docs)
|
||||
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
|
||||
// undo({logPath, undo, redo}) -> Promise<void> tx: apply updates + flip undone
|
||||
// subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null)
|
||||
// subscribeCollection(path, cb) -> unsubscribe fn cb(arr)
|
||||
|
||||
@@ -181,6 +180,61 @@ function runStorageContract(name, factory) {
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
test('undo applies updates + flips undone true', async () => {
|
||||
await storage.setDoc('encounters/e1', { round: 2, name: 'Enc' });
|
||||
await storage.setDoc('logs/l1', { message: 'x', undone: false });
|
||||
await storage.undo({
|
||||
logPath: 'logs/l1',
|
||||
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||
});
|
||||
const enc = await storage.getDoc('encounters/e1');
|
||||
const log = await storage.getDoc('logs/l1');
|
||||
expect(enc.round).toBe(1);
|
||||
expect(log.undone).toBe(true);
|
||||
});
|
||||
|
||||
test('redo applies redo patch + flips undone false', async () => {
|
||||
await storage.setDoc('encounters/e1', { round: 1 });
|
||||
await storage.setDoc('logs/l1', { message: 'x', undone: true });
|
||||
await storage.undo({
|
||||
logPath: 'logs/l1',
|
||||
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||
redo: true,
|
||||
});
|
||||
const enc = await storage.getDoc('encounters/e1');
|
||||
const log = await storage.getDoc('logs/l1');
|
||||
expect(enc.round).toBe(2);
|
||||
expect(log.undone).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCollection', () => {
|
||||
test('deletes all docs in collection', async () => {
|
||||
await storage.addDoc('logs', { type: 'a' });
|
||||
await storage.addDoc('logs', { type: 'b' });
|
||||
const deleted = await storage.deleteCollection('logs');
|
||||
expect(deleted).toBe(2);
|
||||
const remaining = await storage.getCollection('logs');
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('honors whereField filter', async () => {
|
||||
await storage.addDoc('logs', { type: 'keep', n: 1 });
|
||||
await storage.addDoc('logs', { type: 'drop', n: 2 });
|
||||
await storage.addDoc('logs', { type: 'drop', n: 3 });
|
||||
const deleted = await storage.deleteCollection('logs', 'type', 'drop');
|
||||
expect(deleted).toBe(2);
|
||||
const remaining = await storage.getCollection('logs');
|
||||
expect(remaining).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('empty collection returns 0', async () => {
|
||||
const deleted = await storage.deleteCollection('logs');
|
||||
expect(deleted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeDoc', () => {
|
||||
test('fires cb immediately with current value', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
|
||||
+51
-6
@@ -4,13 +4,11 @@
|
||||
// App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
|
||||
// the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import {
|
||||
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
|
||||
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, serverTimestamp,
|
||||
onSnapshot, updateDoc, deleteDoc, query, where, orderBy, limit, writeBatch, getCountFromServer,
|
||||
} from 'firebase/firestore';
|
||||
|
||||
// Adapter call recorder (instrumentation, no behavior change).
|
||||
@@ -102,11 +100,26 @@ export function createFirebaseStorage() {
|
||||
return { id: ref.id, path: `${collectionPath}/${ref.id}` };
|
||||
},
|
||||
|
||||
async getCollection(collectionPath) {
|
||||
const snapshot = await getDocsReal(collection(db, collectionPath));
|
||||
async getCollection(collectionPath, queryConstraints = []) {
|
||||
// Translate neutral {__type} constraints to firebase SDK builders.
|
||||
const fbConstraints = [];
|
||||
for (const c of queryConstraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
||||
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
||||
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
|
||||
// firebase SDK has no offset; skip (UI uses server adapter in dev).
|
||||
}
|
||||
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
|
||||
const snapshot = await getDocsReal(q);
|
||||
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
|
||||
},
|
||||
|
||||
async countCollection(collectionPath) {
|
||||
const snapshot = await getCountFromServer(collection(db, collectionPath));
|
||||
return snapshot.data().count;
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
const batch = writeBatch(db);
|
||||
for (const op of ops) {
|
||||
@@ -117,6 +130,37 @@ export function createFirebaseStorage() {
|
||||
await batch.commit();
|
||||
},
|
||||
|
||||
// Bulk delete collection (optionally where-filtered). Firestore has no
|
||||
// single bulk-by-path op: fetch matching docs, batch-delete in chunks
|
||||
// of 500 (firestore batch limit). DEV ONLY.
|
||||
async deleteCollection(path, whereField, whereValue) {
|
||||
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('deleteCollection: dev only');
|
||||
}
|
||||
let q = collection(db, path);
|
||||
if (whereField) q = query(q, where(whereField, '==', whereValue));
|
||||
const snap = await getDocsReal(q);
|
||||
const docs = snap.docs;
|
||||
let count = 0;
|
||||
for (let i = 0; i < docs.length; i += 500) {
|
||||
const batch = writeBatch(db);
|
||||
for (const d of docs.slice(i, i + 500)) batch.delete(d.ref);
|
||||
await batch.commit();
|
||||
count += Math.min(500, docs.length - i);
|
||||
}
|
||||
return count;
|
||||
},
|
||||
|
||||
// Transactional undo via batch (atomic in firestore). Apply updates +
|
||||
// flip undone flag in single commit.
|
||||
async undo({ logPath, undo, redo = false }) {
|
||||
const batch = writeBatch(db);
|
||||
const updates = redo ? (undo.redo || undo.updates) : undo.updates;
|
||||
batch.update(doc(db, undo.encounterPath), updates);
|
||||
batch.update(doc(db, logPath), { undone: !redo });
|
||||
await batch.commit();
|
||||
},
|
||||
|
||||
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
||||
subscribeDoc(path, cb, errCb) {
|
||||
recordAdapterCall({ fn: 'subscribeDoc', path });
|
||||
@@ -133,6 +177,7 @@ export function createFirebaseStorage() {
|
||||
// queryConstraints = neutral {__type} builders (from index.js).
|
||||
// Translate to SDK orderBy/limit.
|
||||
const sdkConstraints = queryConstraints.map(c => {
|
||||
if (c.__type === 'where') return where(c.field, c.op, c.value);
|
||||
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
|
||||
if (c.__type === 'limit') return limit(c.n);
|
||||
return c; // pass-through (forward compat)
|
||||
|
||||
@@ -43,8 +43,10 @@ export function getStorageMode() {
|
||||
// raw SDK), so both adapters read the same shape. firebase adapter translates
|
||||
// neutral -> SDK; server adapter applies client-side. Combat log uses these:
|
||||
// [orderBy('timestamp','desc'), limit(500)]
|
||||
export function where(field, op, value) { return { __type: 'where', field, op, value }; }
|
||||
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||
export function limit(n) { return { __type: 'limit', n }; }
|
||||
export function offset(n) { return { __type: 'offset', offset: n }; }
|
||||
|
||||
export {
|
||||
initializeApp,
|
||||
|
||||
+81
-43
@@ -2,8 +2,6 @@
|
||||
// Passthrough: no shape translation. Backend = firebase mirror.
|
||||
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
||||
|
||||
'use strict';
|
||||
|
||||
// Native browser WebSocket if present, else ws pkg (Node/jest).
|
||||
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
|
||||
let WebSocketImpl;
|
||||
@@ -42,30 +40,13 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
// Apply neutral {__type} query constraints client-side. Backend returns all
|
||||
// docs; adapter sorts/limits. Mirrors firebase mock applyConstraints.
|
||||
function applyConstraints(docs, constraints) {
|
||||
let out = [...docs];
|
||||
for (const c of constraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'orderBy') {
|
||||
out.sort((a, b) => {
|
||||
const av = a[c.field];
|
||||
const bv = b[c.field];
|
||||
if (av === bv) return 0;
|
||||
const cmp = av > bv ? 1 : -1;
|
||||
return c.dir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
} else if (c.__type === 'limit') {
|
||||
out = out.slice(0, c.n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const docSubs = new Map(); // path -> Set<cb>
|
||||
const collSubs = new Map(); // collPath -> Set<cb>
|
||||
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
|
||||
// Last data emitted per path. Dedupe identical re-emits so WS-open
|
||||
// re-fetch (authoritative catch-up) doesn't double-fire when REST already
|
||||
// delivered the same data. Race: REST empty → WS-open populated fires.
|
||||
const lastEmitted = new Map(); // path -> serialized data (or null for empty)
|
||||
let ws = null;
|
||||
let wsReady = null;
|
||||
|
||||
@@ -74,6 +55,16 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
let everConnected = false;
|
||||
const RECONNECT_DELAY = 500;
|
||||
|
||||
// Emit helper: dedupe identical re-emits per path. WS-open re-fetch
|
||||
// (authoritative catch-up) + initial REST both may fire; skip dup.
|
||||
function emit(kind, path, data, cbs) {
|
||||
const key = `${kind}:${path}`;
|
||||
const ser = data === undefined ? 'undef' : JSON.stringify(data);
|
||||
if (lastEmitted.get(key) === ser) return; // identical, skip
|
||||
lastEmitted.set(key, ser);
|
||||
cbs.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
function ensureWs() {
|
||||
if (wsReady) return wsReady;
|
||||
wsReady = new Promise((resolve, reject) => {
|
||||
@@ -96,7 +87,7 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
for (const p of collSubs.keys()) {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||
}
|
||||
// On RECONNECT only: re-fetch current values — catches writes that
|
||||
// Reconnect only: re-fetch current values — catches writes that
|
||||
// happened while disconnected (broadcast missed). Skip on first connect
|
||||
// (initial REST fetch in subscribeDoc/subscribeCollection already did).
|
||||
if (isReconnect) {
|
||||
@@ -104,7 +95,8 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
|
||||
}
|
||||
for (const [p, cbs] of collSubs) {
|
||||
storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
|
||||
const cstr = collConstraints.get(p) || [];
|
||||
storage.getCollection(p, cstr).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
|
||||
}
|
||||
}
|
||||
resolve(ws);
|
||||
@@ -127,16 +119,14 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
|
||||
handleMessage(msg);
|
||||
};
|
||||
// Bind ONCE. Browser WS + ws pkg are EventTargets: assigning onX AND
|
||||
// addEventListener('x') registers the handler twice -> every event
|
||||
// fires 2x (double onOpen -> spurious reconnect re-fetch, double
|
||||
// onMessage -> emit dedupe races under write load -> UI looks stale).
|
||||
ws.onopen = onOpen;
|
||||
ws.onerror = onError;
|
||||
ws.onclose = onClose;
|
||||
ws.onmessage = onMessage;
|
||||
if (typeof ws.addEventListener === 'function') {
|
||||
ws.addEventListener('open', onOpen);
|
||||
ws.addEventListener('error', onError);
|
||||
ws.addEventListener('close', onClose);
|
||||
ws.addEventListener('message', onMessage);
|
||||
}
|
||||
})();
|
||||
});
|
||||
return wsReady;
|
||||
@@ -144,7 +134,9 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
|
||||
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
|
||||
async function handleMessage(msg) {
|
||||
if (msg.type !== 'change' || !msg.change) return;
|
||||
if (msg.type !== 'change' || !msg.change) {
|
||||
return;
|
||||
}
|
||||
const c = msg.change;
|
||||
// doc subscriber at exact changed path
|
||||
const docCbs = docSubs.get(c.path);
|
||||
@@ -157,7 +149,8 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
const collCbs = collSubs.get(c.parent);
|
||||
if (collCbs) {
|
||||
const constraints = collConstraints.get(c.parent) || [];
|
||||
const docs = applyConstraints(await storage.getCollection(c.parent), constraints);
|
||||
// Server honors orderBy + limit in SQL now.
|
||||
const docs = await storage.getCollection(c.parent, constraints);
|
||||
collCbs.forEach(cb => cb(docs));
|
||||
}
|
||||
}
|
||||
@@ -211,17 +204,46 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
await api('DELETE', '/api/doc', { path: p });
|
||||
},
|
||||
|
||||
// Bulk delete collection (optionally where-filtered). No fetch. SQL DELETE.
|
||||
// DEV ONLY. Method ships in prod build but throws if not dev.
|
||||
async deleteCollection(rawCollPath, whereField, whereValue) {
|
||||
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('deleteCollection: dev only');
|
||||
}
|
||||
const p = norm(rawCollPath);
|
||||
const query = { path: p };
|
||||
if (whereField) { query.whereField = whereField; query.whereValue = whereValue; }
|
||||
const res = await api('DELETE', '/api/collection', query);
|
||||
return res.deleted;
|
||||
},
|
||||
|
||||
async addDoc(rawCollPath, data) {
|
||||
const p = norm(rawCollPath);
|
||||
const res = await api('POST', '/api/collection', null, { path: p, data });
|
||||
return { id: res.id, path: res.path };
|
||||
},
|
||||
|
||||
async getCollection(rawCollPath) {
|
||||
async getCollection(rawCollPath, queryConstraints = []) {
|
||||
const p = norm(rawCollPath);
|
||||
const docs = await api('GET', '/api/collection', { path: p });
|
||||
// Backend returns array of { id, data } OR bare data[]; normalize to
|
||||
// { id, ...data } (firebase truth).
|
||||
const query = { path: p };
|
||||
// Push where + orderBy + limit + offset to server SQL when present.
|
||||
// Avoids client-side full-scan + re-sort on every WS change notification.
|
||||
for (const c of queryConstraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'where') {
|
||||
query.whereField = c.field;
|
||||
query.whereOp = c.op;
|
||||
query.whereValue = c.value;
|
||||
} else if (c.__type === 'orderBy') {
|
||||
query.orderBy = c.field;
|
||||
query.dir = c.dir || 'asc';
|
||||
} else if (c.__type === 'limit') {
|
||||
query.limit = c.n;
|
||||
} else if (c.__type === 'offset') {
|
||||
query.offset = c.offset;
|
||||
}
|
||||
}
|
||||
const docs = await api('GET', '/api/collection', query);
|
||||
if (!Array.isArray(docs)) return [];
|
||||
return docs.map(d => {
|
||||
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
|
||||
@@ -231,15 +253,31 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
});
|
||||
},
|
||||
|
||||
async countCollection(rawCollPath) {
|
||||
const p = norm(rawCollPath);
|
||||
const res = await api('GET', '/api/collection/count', { path: p });
|
||||
return (res && typeof res.count === 'number') ? res.count : 0;
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
|
||||
await api('POST', '/api/batch', null, { ops: normOps });
|
||||
},
|
||||
|
||||
// Transactional undo (server mode only). Firebase falls back to 2-write
|
||||
// in App.js (via store.txUndo check). undo = { encounterPath, updates, redo }.
|
||||
// redo=true applies redo patch + flips undone:false.
|
||||
async undo({ logPath, undo, redo = false }) {
|
||||
const normUndo = { ...undo, encounterPath: norm(undo.encounterPath) };
|
||||
const normLog = norm(logPath);
|
||||
await api('POST', '/api/undo', null, { logPath: normLog, undo: normUndo, redo });
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb, errCb) {
|
||||
const p = norm(rawPath);
|
||||
lastEmitted.delete('doc:'+p); // fresh sub → allow first emit
|
||||
// Initial value via REST (independent of WS connect). getDoc injects id.
|
||||
storage.getDoc(p).then(cb).catch(() => {});
|
||||
storage.getDoc(p).then(doc => emit('doc', p, doc, docSubs.get(p) || new Set([cb]))).catch(() => {});
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||
@@ -252,11 +290,11 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
|
||||
const p = norm(rawCollPath);
|
||||
collConstraints.set(p, queryConstraints || []);
|
||||
// Initial value via REST (independent of WS connect). Apply constraints
|
||||
// client-side — backend returns all docs, adapter sorts/limits.
|
||||
storage.getCollection(p)
|
||||
.then(docs => cb(applyConstraints(docs, queryConstraints || [])))
|
||||
.catch(() => {});
|
||||
lastEmitted.delete('coll:'+p); // fresh sub → allow first emit
|
||||
// Server honors orderBy + limit in SQL now. No client re-sort.
|
||||
storage.getCollection(p, queryConstraints || [])
|
||||
.then(docs => emit('coll', p, docs, collSubs.get(p) || new Set([cb])))
|
||||
.catch(err => { if (errCb) errCb(err); });
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName, setupReady, startCombatViaUI, addMonsterViaUI } from './testHelpers';
|
||||
|
||||
function findCall(fn, pathSub) {
|
||||
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||
@@ -139,4 +139,53 @@ describe('Campaign -> Firebase', () => {
|
||||
const delCall = findCall('deleteDoc', `/campaigns/${cid}`);
|
||||
expect(delCall).toBeDefined();
|
||||
});
|
||||
|
||||
test('deleteCampaign: cascade-deletes logs for campaign encounters (BUG)', async () => {
|
||||
const { setupReady, startCombatViaUI } = require('./testHelpers');
|
||||
await setupReady('Doomed', 'Enc');
|
||||
await addMonsterViaUI('Gob', 5, 2);
|
||||
await startCombatViaUI();
|
||||
// log written on start
|
||||
await waitFor(() => {
|
||||
const logWrites = findCalls('addDoc').filter(c => c.path.includes('/logs'));
|
||||
expect(logWrites.length).toBeGreaterThan(0);
|
||||
});
|
||||
const cid = Object.keys(MOCK_DB.collection('campaigns').reduce((m,c)=>(m[c.id]=c,m),{}))[0];
|
||||
|
||||
// delete campaign
|
||||
const allDeletes = screen.getAllByText(/Delete/i);
|
||||
fireEvent.click(allDeletes[allDeletes.length - 1]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
|
||||
|
||||
// logs MUST be deleted via batch or deleteDoc on each log path
|
||||
const logDeletes = getCalls().filter(c =>
|
||||
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
|
||||
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
|
||||
);
|
||||
expect(logDeletes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('deleteEncounter: cascade-deletes logs for encounter (BUG)', async () => {
|
||||
const { setupReady, startCombatViaUI } = require('./testHelpers');
|
||||
await setupReady('Camp', 'DoomedEnc');
|
||||
await addMonsterViaUI('Gob', 5, 2);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => {
|
||||
expect(findCalls('addDoc').filter(c => c.path.includes('/logs')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// delete encounter
|
||||
const allDeletes = screen.getAllByText(/Delete/i);
|
||||
fireEvent.click(allDeletes[0]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const logDeletes = getCalls().filter(c =>
|
||||
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
|
||||
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
|
||||
);
|
||||
expect(logDeletes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+212
-168
@@ -3,55 +3,43 @@
|
||||
//
|
||||
// Does 100 ROUNDS (not turns). A round = one full pass through initiative
|
||||
// (every active participant acts once); round counter increments at wrap.
|
||||
// See docs/GLOSSARY.md. Previous version lied: called nextTurn 100× (~100
|
||||
// turns, ~12 rounds). Now loops by actual round-wrap count.
|
||||
//
|
||||
// Exercises ALL combat paths deterministically (less random than replay):
|
||||
// Exercises ALL combat paths deterministically:
|
||||
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
|
||||
// updateParticipant, removeParticipant, toggleParticipantActive,
|
||||
// applyHpChange (damage/heal), deathSave (incl. 3-success → isDying),
|
||||
// toggleCondition, reorderParticipants (drag same-init tie), endEncounter,
|
||||
// activateDisplay, clearDisplay, toggleHidePlayerHp.
|
||||
// applyHpChange (damage/heal), deathSave, toggleCondition,
|
||||
// reorderParticipants, endEncounter.
|
||||
//
|
||||
// New API: funcs async, take ctx {storage, encPath, logPath, displayPath},
|
||||
// write encounter + log internally, return newEnc. Mock storage captures writes.
|
||||
// Display lifecycle now async too — but test keeps inline display writes (mirrors App.js).
|
||||
//
|
||||
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
|
||||
// failures collected, final expect reports all.
|
||||
|
||||
const path = require('path');
|
||||
const { createMockStorage, mockCtx, readyEnc } = require('../../shared/tests/_helpers');
|
||||
const {
|
||||
makeParticipant,
|
||||
buildCharacterParticipant,
|
||||
buildMonsterParticipant,
|
||||
startEncounter,
|
||||
nextTurn,
|
||||
togglePause,
|
||||
addParticipant,
|
||||
addParticipants,
|
||||
updateParticipant,
|
||||
removeParticipant,
|
||||
toggleParticipantActive,
|
||||
applyHpChange,
|
||||
deathSave,
|
||||
toggleCondition,
|
||||
reorderParticipants,
|
||||
endEncounter,
|
||||
activateDisplay,
|
||||
clearDisplay,
|
||||
toggleHidePlayerHp,
|
||||
} = require('../../shared');
|
||||
|
||||
// aliases for combat funcs that clash with local helper names
|
||||
const {
|
||||
applyHpChange: combatApplyHpChange,
|
||||
startEncounter, nextTurn, togglePause, endEncounter,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive: combatToggleActive,
|
||||
toggleCondition: combatToggleCondition,
|
||||
applyHpChange: combatApplyHpChange,
|
||||
deathSave: combatDeathSave,
|
||||
stabilizeParticipant,
|
||||
reviveParticipant,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
} = require('../../shared');
|
||||
|
||||
// ---------- scenario state (mirrors two firestore docs) ----------
|
||||
// ---------- scenario state ----------
|
||||
|
||||
const ROUNDS = 100;
|
||||
const ENCOUNTER_ID = 'enc-1';
|
||||
|
||||
// encounter doc
|
||||
let enc = {
|
||||
id: ENCOUNTER_ID,
|
||||
name: 'BigBoss',
|
||||
participants: [],
|
||||
isStarted: false,
|
||||
@@ -60,7 +48,7 @@ let enc = {
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
};
|
||||
// activeDisplay/status doc
|
||||
// display tracked via mock storage displayPath doc; simple local mirror.
|
||||
let display = {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null,
|
||||
@@ -68,222 +56,288 @@ let display = {
|
||||
hideNpcHp: false,
|
||||
};
|
||||
const CAMPAIGN_ID = 'camp-1';
|
||||
const ENCOUNTER_ID = 'enc-1';
|
||||
const roster = [];
|
||||
const CAMPAIGN_PATH = `campaigns/${CAMPAIGN_ID}`;const roster = [];
|
||||
|
||||
function applyEnc(patch) {
|
||||
if (!patch) return;
|
||||
for (const [k, v] of Object.entries(patch)) enc[k] = v;
|
||||
}
|
||||
function applyDisplay(patch) {
|
||||
if (!patch) return;
|
||||
for (const [k, v] of Object.entries(patch)) display[k] = v;
|
||||
}
|
||||
let storage, ctx;
|
||||
const DISPLAY_PATH = 'activeDisplay/status';
|
||||
|
||||
const RESULTS = [];
|
||||
function record(phase, fn) {
|
||||
try { fn(); RESULTS.push({ phase, ok: true }); }
|
||||
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||
}
|
||||
async function recordAsync(phase, fn) {
|
||||
async function record(phase, fn) {
|
||||
try { await fn(); RESULTS.push({ phase, ok: true }); }
|
||||
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||
}
|
||||
|
||||
// ---------- helpers (shared, no React) ----------
|
||||
// ---------- helpers ----------
|
||||
|
||||
const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
|
||||
const any = (name) => enc.participants.find(p => p.name === name);
|
||||
const idOf = (name) => { const p = any(name); return p ? p.id : null; };
|
||||
const anyById = (id) => enc.participants.find(p => p.id === id);
|
||||
|
||||
function addCharacterViaUI(name, maxHp, initMod) {
|
||||
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
|
||||
const character = { id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod };
|
||||
roster.push(character);
|
||||
// persist to campaign doc (mirrors app: players array)
|
||||
if (storage) {
|
||||
const existing = (storage.docs.get(CAMPAIGN_PATH) || {}).players || [];
|
||||
storage.setDoc(CAMPAIGN_PATH, {
|
||||
id: CAMPAIGN_ID, name: 'Scenario Campaign', createdAt: Date.now(),
|
||||
players: [...existing, character],
|
||||
});
|
||||
}
|
||||
}
|
||||
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
|
||||
applyEnc(addParticipant(enc, participant).patch);
|
||||
|
||||
async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
|
||||
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
|
||||
enc = await addParticipant(enc, participant, ctx);
|
||||
}
|
||||
function addCharacterParticipant(charName) {
|
||||
async function addCharacterParticipant(charName) {
|
||||
const character = roster.find(c => c.name === charName);
|
||||
if (!character) throw new Error(`char not found: ${charName}`);
|
||||
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
|
||||
throw new Error(`${charName} already in encounter`);
|
||||
}
|
||||
const { participant } = buildCharacterParticipant(character);
|
||||
applyEnc(addParticipant(enc, participant).patch);
|
||||
enc = await addParticipant(enc, participant, ctx);
|
||||
}
|
||||
function addAllCharacters() {
|
||||
async function addAllCharacters() {
|
||||
const toAdd = roster
|
||||
.filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
|
||||
.map(c => buildCharacterParticipant(c).participant);
|
||||
applyEnc(addParticipants(enc, toAdd).patch);
|
||||
enc = await addParticipants(enc, toAdd, ctx);
|
||||
}
|
||||
function startCombat() {
|
||||
applyEnc(startEncounter(enc).patch);
|
||||
applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
|
||||
async function startCombat() {
|
||||
enc = await startEncounter(enc, ctx);
|
||||
display.activeCampaignId = CAMPAIGN_ID;
|
||||
display.activeEncounterId = ENCOUNTER_ID;
|
||||
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: CAMPAIGN_ID, activeEncounterId: ENCOUNTER_ID });
|
||||
}
|
||||
function endCombat() {
|
||||
applyEnc(endEncounter(enc).patch);
|
||||
applyDisplay(clearDisplay().patch);
|
||||
async function endCombat() {
|
||||
enc = await endEncounter(enc, ctx);
|
||||
display.activeCampaignId = null;
|
||||
display.activeEncounterId = null;
|
||||
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: null, activeEncounterId: null });
|
||||
}
|
||||
function nextTurnAction() { applyEnc(nextTurn(enc).patch); }
|
||||
function pauseCombat() { applyEnc(togglePause(enc).patch); if (!enc.isPaused) throw new Error('not paused'); }
|
||||
function resumeCombat() { applyEnc(togglePause(enc).patch); if (enc.isPaused) throw new Error('not resumed'); }
|
||||
async function nextTurnAction() { enc = await nextTurn(enc, ctx); }
|
||||
async function pauseCombat() { enc = await togglePause(enc, ctx); if (!enc.isPaused) throw new Error('not paused'); }
|
||||
async function resumeCombat() { enc = await togglePause(enc, ctx); if (enc.isPaused) throw new Error('not resumed'); }
|
||||
|
||||
function applyDamage(name, amount) {
|
||||
async function applyDamage(name, amount, options = null) {
|
||||
const p = any(name);
|
||||
if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
|
||||
applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
|
||||
if (!p || (p.currentHp <= 0 && p.status === 'dead')) return;
|
||||
enc = options
|
||||
? await combatApplyHpChange(enc, p.id, 'damage', amount, options, ctx)
|
||||
: await combatApplyHpChange(enc, p.id, 'damage', amount, ctx);
|
||||
}
|
||||
function applyHeal(name, amount) {
|
||||
async function applyHeal(name, amount) {
|
||||
const p = any(name);
|
||||
if (!p) return; // removed (death) = skip (no button in UI)
|
||||
applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
|
||||
if (!p) return;
|
||||
enc = await combatApplyHpChange(enc, p.id, 'heal', amount, ctx);
|
||||
}
|
||||
function toggleActive(name) {
|
||||
async function toggleActive(name) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no active target: ${name}`);
|
||||
applyEnc(combatToggleActive(enc, p.id).patch);
|
||||
enc = await combatToggleActive(enc, p.id, ctx);
|
||||
}
|
||||
function toggleConditionAction(name, label) {
|
||||
async function toggleConditionAction(name, label) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no condition target: ${name}`);
|
||||
applyEnc(combatToggleCondition(enc, p.id, label).patch);
|
||||
enc = await combatToggleCondition(enc, p.id, label, ctx);
|
||||
}
|
||||
function editParticipant(name, patch) {
|
||||
async function editParticipant(name, patch) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no edit target: ${name}`);
|
||||
applyEnc(updateParticipant(enc, p.id, patch).patch);
|
||||
enc = await updateParticipant(enc, p.id, patch, ctx);
|
||||
}
|
||||
function removeParticipantByName(name) {
|
||||
async function removeParticipantByName(name) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no remove target: ${name}`);
|
||||
applyEnc(removeParticipant(enc, p.id).patch);
|
||||
enc = await removeParticipant(enc, p.id, ctx);
|
||||
}
|
||||
function deathSaveAction(name, type, saveNum) {
|
||||
async function deathSaveAction(name, outcome) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no deathsave target: ${name}`);
|
||||
applyEnc(combatDeathSave(enc, p.id, type, saveNum).patch);
|
||||
const r = await combatDeathSave(enc, p.id, outcome, ctx);
|
||||
enc = r.enc;
|
||||
}
|
||||
function dragTie(draggedName, targetName) {
|
||||
async function stabilizeAction(name) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no stabilize target: ${name}`);
|
||||
enc = await stabilizeParticipant(enc, p.id, ctx);
|
||||
}
|
||||
async function reviveAction(name) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no revive target: ${name}`);
|
||||
enc = await reviveParticipant(enc, p.id, ctx);
|
||||
}
|
||||
async function dragTie(draggedName, targetName) {
|
||||
const d = any(draggedName), t = any(targetName);
|
||||
if (!d || !t) throw new Error('drag target missing');
|
||||
applyEnc(reorderParticipants(enc, d.id, t.id).patch);
|
||||
enc = await reorderParticipants(enc, d.id, t.id, ctx);
|
||||
}
|
||||
function toggleHidePlayerHpAction() {
|
||||
applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
|
||||
async function toggleHidePlayerHpAction() {
|
||||
display.hidePlayerHp = !display.hidePlayerHp;
|
||||
await storage.updateDoc(DISPLAY_PATH, { hidePlayerHp: display.hidePlayerHp });
|
||||
}
|
||||
|
||||
// ---------- scenario ----------
|
||||
|
||||
beforeEach(() => {
|
||||
({ storage, ctx } = mockCtx());
|
||||
enc = {
|
||||
id: ENCOUNTER_ID, name: 'BigBoss', participants: [],
|
||||
isStarted: false, isPaused: false, round: 0,
|
||||
currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false };
|
||||
roster.length = 0;
|
||||
RESULTS.length = 0;
|
||||
});
|
||||
|
||||
test('death-save edge cases in combat scenario helpers', async () => {
|
||||
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||
await record('addAllChars', () => addAllCharacters());
|
||||
await record('addMonster Goblin', () => addMonsterParticipant('Goblin', 8, 2));
|
||||
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||
await record('startCombat', () => startCombat());
|
||||
|
||||
await record('monster drops dead inactive', async () => {
|
||||
await applyDamage('Goblin', any('Goblin').currentHp);
|
||||
const g = any('Goblin');
|
||||
expect(g.status).toBe('dead');
|
||||
expect(g.isActive).toBe(false);
|
||||
});
|
||||
|
||||
await record('npc nat20 returns conscious', async () => {
|
||||
await applyDamage('Merchant', any('Merchant').currentHp);
|
||||
expect(any('Merchant').status).toBe('dying');
|
||||
await deathSaveAction('Merchant', 'nat20');
|
||||
expect(any('Merchant').status).toBe('conscious');
|
||||
expect(any('Merchant').currentHp).toBe(1);
|
||||
});
|
||||
|
||||
await record('npc nat1 plus damage kills, revive/heal restores', async () => {
|
||||
await applyDamage('Merchant', any('Merchant').currentHp);
|
||||
await deathSaveAction('Merchant', 'nat1');
|
||||
expect(any('Merchant').deathSaveFailures).toBe(2);
|
||||
await applyDamage('Merchant', 1);
|
||||
expect(any('Merchant').status).toBe('dead');
|
||||
expect(any('Merchant').isActive).toBe(true);
|
||||
await reviveAction('Merchant');
|
||||
expect(any('Merchant').status).toBe('stable');
|
||||
await applyHeal('Merchant', 5);
|
||||
expect(any('Merchant').status).toBe('conscious');
|
||||
expect(any('Merchant').currentHp).toBe(5);
|
||||
});
|
||||
|
||||
await record('character stable then crit damage dies', async () => {
|
||||
await applyDamage('Fighter', any('Fighter').currentHp);
|
||||
await deathSaveAction('Fighter', 'success');
|
||||
await deathSaveAction('Fighter', 'success');
|
||||
await deathSaveAction('Fighter', 'success');
|
||||
expect(any('Fighter').status).toBe('stable');
|
||||
await applyDamage('Fighter', 1);
|
||||
expect(any('Fighter').status).toBe('dying');
|
||||
expect(any('Fighter').deathSaveFailures).toBe(1);
|
||||
await applyDamage('Fighter', 1, { isCriticalHit: true });
|
||||
expect(any('Fighter').status).toBe('dead');
|
||||
expect(any('Fighter').isActive).toBe(true);
|
||||
});
|
||||
|
||||
await record('massive damage kills character but does not deactivate', async () => {
|
||||
await applyDamage('Cleric', any('Cleric').currentHp + any('Cleric').maxHp);
|
||||
expect(any('Cleric').status).toBe('dead');
|
||||
expect(any('Cleric').isActive).toBe(true);
|
||||
});
|
||||
|
||||
const failed = RESULTS.filter(x => !x.ok);
|
||||
if (failed.length > 0) {
|
||||
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
|
||||
}
|
||||
expect(failed).toEqual([]);
|
||||
});
|
||||
|
||||
test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
// roster
|
||||
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||
await recordAsync('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
|
||||
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||
await record('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
|
||||
|
||||
// monsters + npcs
|
||||
await recordAsync('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
|
||||
await recordAsync('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
|
||||
await recordAsync('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
|
||||
await recordAsync('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
|
||||
await recordAsync('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||
await record('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
|
||||
await record('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
|
||||
await record('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
|
||||
await record('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
|
||||
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||
|
||||
// add chars into encounter
|
||||
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
||||
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
|
||||
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
|
||||
await recordAsync('addAllChars', () => addAllCharacters()); // bulk add path
|
||||
await record('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
||||
await record('addAllChars Cleric/Rogue', () => addAllCharacters());
|
||||
|
||||
// hidden hp toggle x2 (back to default)
|
||||
record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||||
record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||||
await record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||||
await record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||||
|
||||
await recordAsync('startCombat', () => startCombat());
|
||||
await record('startCombat', () => startCombat());
|
||||
|
||||
// ---------- 100 ROUNDS (loop by actual round-wrap count) ----------
|
||||
// ---------- 100 ROUNDS ----------
|
||||
let roundsDone = 0;
|
||||
let prevRound = enc.round;
|
||||
let turnInRound = 0;
|
||||
let turnTotal = 0;
|
||||
|
||||
while (roundsDone < ROUNDS) {
|
||||
turnTotal++;
|
||||
const actor = anyById(enc.currentTurnParticipantId);
|
||||
const r = enc.round;
|
||||
|
||||
// per-turn deterministic actions keyed by round + actor type
|
||||
// damage OrcBoss every even round
|
||||
if (r % 2 === 0 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||
}
|
||||
// heal Cleric every 3 rounds
|
||||
if (r % 3 === 0 && turnInRound === 1) {
|
||||
record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||
}
|
||||
// condition on Fighter every 5 rounds
|
||||
if (r % 5 === 0 && turnInRound === 2) {
|
||||
record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
||||
}
|
||||
// toggleActive Goblin2 every 7 rounds (toggle off, next 7 toggle on)
|
||||
if (r % 7 === 0 && turnInRound === 3) {
|
||||
record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||
}
|
||||
// edit Wolf initiative every 13 rounds
|
||||
if (r % 13 === 0 && turnInRound === 4) {
|
||||
record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||
}
|
||||
if (r % 2 === 0 && turnInRound === 0) await record(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||
if (r % 3 === 0 && turnInRound === 1) await record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||
if (r % 5 === 0 && turnInRound === 2) await record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
||||
if (r % 7 === 0 && turnInRound === 3) await record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||
if (r % 13 === 0 && turnInRound === 4) await record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||
|
||||
// pause/resume + add reinforcement + edit every 10 rounds
|
||||
if (r % 10 === 0 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} pause`, () => pauseCombat());
|
||||
await recordAsync(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||
await recordAsync(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||
await recordAsync(`r${r} resume`, () => resumeCombat());
|
||||
await record(`r${r} pause`, () => pauseCombat());
|
||||
await record(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||
await record(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||
await record(`r${r} resume`, () => resumeCombat());
|
||||
}
|
||||
|
||||
// create a same-init tie + drag every 15 rounds (exercises reorderParticipants)
|
||||
if (r % 15 === 0 && turnInRound === 5) {
|
||||
const g1 = any('Goblin1');
|
||||
if (g1 && alive('Goblin2')) {
|
||||
record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
||||
record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
||||
await record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
||||
await record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
||||
}
|
||||
}
|
||||
|
||||
// death scenarios: drop a PC to 0, death saves, revive
|
||||
if (r === 25 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||||
record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
|
||||
record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
|
||||
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
|
||||
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
}
|
||||
// round 50: full 3-success death-save path (isDying) on Cleric, then remove
|
||||
if (r === 50 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||
await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => {
|
||||
deathSaveAction('Cleric', 'fail', 1);
|
||||
deathSaveAction('Cleric', 'fail', 2);
|
||||
deathSaveAction('Cleric', 'fail', 3); // → dead
|
||||
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
|
||||
await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
|
||||
await deathSaveAction('Cleric', 'fail');
|
||||
await deathSaveAction('Cleric', 'fail');
|
||||
await deathSaveAction('Cleric', 'fail');
|
||||
});
|
||||
const cl = any('Cleric');
|
||||
if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
|
||||
if (cl) {
|
||||
expect(cl.status).toBe('dead');
|
||||
expect(enc.participants.map(p => p.name)).toContain('Cleric');
|
||||
}
|
||||
}
|
||||
|
||||
// remove a reinforcement every 30 rounds
|
||||
if (r % 30 === 0 && turnInRound === 1) {
|
||||
const rein = any(`Reinforce${r - 10}`);
|
||||
if (rein) record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
||||
if (rein) await record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
||||
}
|
||||
|
||||
// toggle hide hp every 20 rounds
|
||||
if (r % 20 === 0 && turnInRound === 0) {
|
||||
record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
||||
record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
||||
await record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
||||
await record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
||||
}
|
||||
|
||||
// rotation integrity check every 10 rounds at round start
|
||||
if (turnInRound === 0 && r % 10 === 0) {
|
||||
record(`r${r} rotation-check`, () => {
|
||||
await record(`r${r} rotation-check`, () => {
|
||||
const order = enc.turnOrderIds || [];
|
||||
const uniq = new Set(order);
|
||||
if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
||||
@@ -297,11 +351,9 @@ test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// advance turn
|
||||
await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
||||
await record(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
||||
turnInRound++;
|
||||
|
||||
// round wrap detected
|
||||
if (enc.round > prevRound) {
|
||||
roundsDone++;
|
||||
prevRound = enc.round;
|
||||
@@ -309,9 +361,8 @@ test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
await recordAsync('endCombat', () => endCombat());
|
||||
await record('endCombat', () => endCombat());
|
||||
|
||||
// ---------- report ----------
|
||||
const failed = RESULTS.filter(x => !x.ok);
|
||||
if (failed.length > 0) {
|
||||
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
||||
@@ -321,14 +372,7 @@ test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
expect(failed).toEqual([]);
|
||||
expect(roundsDone).toBe(ROUNDS);
|
||||
|
||||
// lifecycle assertions (activeDisplay mirrors combat state)
|
||||
expect(enc.isStarted).toBe(false);
|
||||
expect(enc.round).toBe(0);
|
||||
expect(enc.currentTurnParticipantId).toBeNull();
|
||||
expect(display.activeCampaignId).toBeNull();
|
||||
expect(display.activeEncounterId).toBeNull();
|
||||
});
|
||||
|
||||
function anyById(id) {
|
||||
return enc.participants.find(p => p.id === id);
|
||||
}
|
||||
|
||||
@@ -4,29 +4,46 @@
|
||||
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import App from '../App';
|
||||
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
|
||||
|
||||
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
|
||||
function seedActiveDisplay() {
|
||||
function seedActiveDisplay(participants = []) {
|
||||
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
|
||||
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
|
||||
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||
|
||||
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
|
||||
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
|
||||
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||
}
|
||||
|
||||
function participant(status) {
|
||||
return {
|
||||
id: status,
|
||||
name: status === 'dying' ? 'Rogue' : status,
|
||||
type: 'character',
|
||||
initiative: 18,
|
||||
maxHp: 160,
|
||||
currentHp: status === 'conscious' ? 10 : 0,
|
||||
isActive: true,
|
||||
status,
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DisplayView characterization', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/display');
|
||||
global.alert = jest.fn();
|
||||
window.open = jest.fn();
|
||||
resetAdapterCalls();
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
@@ -57,4 +74,36 @@ describe('DisplayView characterization', () => {
|
||||
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
|
||||
seedActiveDisplay([participant('dying')]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
|
||||
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
|
||||
seedActiveDisplay([participant('stable'), participant('dead')]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
|
||||
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('DisplayView hides inactive participants for all types', async () => {
|
||||
seedActiveDisplay([
|
||||
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
|
||||
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
|
||||
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
|
||||
]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,171 +1,174 @@
|
||||
// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save.
|
||||
// Logs + death-save UI characterization. Lock log writes, undo, clear, and death-save flow.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||
|
||||
function findLogCalls() {
|
||||
return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs'));
|
||||
function findCalls(fn, includes) {
|
||||
return getCalls().filter(c => c.fn === fn && (!includes || c.path.includes(includes)));
|
||||
}
|
||||
function lastEncCall() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const calls = findCalls('updateDoc', '/encounters/');
|
||||
return calls[calls.length - 1];
|
||||
}
|
||||
|
||||
// Navigate to /logs view. App reads pathname at mount; must re-render with path preset.
|
||||
import { render } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
async function goToLogs() {
|
||||
// unmount current tree isn't needed; App checks pathname in useEffect.
|
||||
// Re-render a fresh App instance in same container.
|
||||
window.history.replaceState({}, '', '/logs');
|
||||
document.body.innerHTML = '';
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByText(/Combat Log/i));
|
||||
async function addCharacterToEncounter(name = 'Hero', hp = 10) {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI(`DS-${name}`);
|
||||
await selectCampaignByName(`DS-${name}`);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
|
||||
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
|
||||
|
||||
await createEncounterViaUI(`Enc-${name}`);
|
||||
await selectEncounterByName(`Enc-${name}`);
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === name);
|
||||
|
||||
await startCombatViaUI();
|
||||
return { charId };
|
||||
}
|
||||
|
||||
async function dropFirstParticipantToZero(hp = 10) {
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: String(hp) } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
}
|
||||
|
||||
describe('Logs -> Firebase', () => {
|
||||
test('logAction: addDoc to logs collection on combat start', async () => {
|
||||
await setupReady('LogCamp', 'LogEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('LogCamp');
|
||||
await selectCampaignByName('LogCamp');
|
||||
await createEncounterViaUI('LogEnc');
|
||||
await selectEncounterByName('LogEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||
expect(logCall.data).toHaveProperty('message');
|
||||
expect(logCall.data).toHaveProperty('timestamp');
|
||||
expect(logCall.data.message).toMatch(/Combat started/);
|
||||
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
|
||||
expect(findCalls('addDoc', '/logs').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('logAction: includes undo payload', async () => {
|
||||
await setupReady('UndoCamp', 'UndoEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
test('logAction: includes lean undo data', async () => {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('UndoDataCamp');
|
||||
await selectCampaignByName('UndoDataCamp');
|
||||
await createEncounterViaUI('UndoDataEnc');
|
||||
await selectEncounterByName('UndoDataEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||
expect(logCall.data.undo).toBeTruthy();
|
||||
expect(logCall.data.undo).toHaveProperty('updates');
|
||||
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
|
||||
const log = findCalls('addDoc', '/logs').pop().data;
|
||||
expect(log).toHaveProperty('undo');
|
||||
});
|
||||
|
||||
test('clearLogs: writeBatch deletes all log docs', async () => {
|
||||
const { renderApp } = require('./testHelpers');
|
||||
// seed a log entry via combat start
|
||||
await setupReady('ClearCamp', 'ClearEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('ClearLogs');
|
||||
await selectCampaignByName('ClearLogs');
|
||||
await createEncounterViaUI('ClearLogsEnc');
|
||||
await selectEncounterByName('ClearLogsEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().length > 0);
|
||||
|
||||
await goToLogs();
|
||||
const clearBtn = await screen.findByRole('button', { name: /Clear Log/i });
|
||||
fireEvent.click(clearBtn);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
|
||||
return batchDeletes.length > 0;
|
||||
});
|
||||
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
|
||||
expect(batchDeletes.length).toBeGreaterThan(0);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('undo: updateDoc on encounter path + marks log undone', async () => {
|
||||
// seed log via combat start
|
||||
await setupReady('UndoFlowCamp', 'UndoFlowEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
test('undo: tx batch marks log undone + updates encounter', async () => {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('UndoLogs');
|
||||
await selectCampaignByName('UndoLogs');
|
||||
await createEncounterViaUI('UndoLogsEnc');
|
||||
await selectEncounterByName('UndoLogsEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().length > 0);
|
||||
const logId = findLogCalls()[0].path.split('/').pop();
|
||||
|
||||
await goToLogs();
|
||||
const undoBtns = await screen.findAllByRole('button', { name: /Undo/i });
|
||||
fireEvent.click(undoBtns[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
return und;
|
||||
});
|
||||
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
expect(markUndone.data.undone).toBe(true);
|
||||
// encounter path updated with undo payload (any encounter update after undo click)
|
||||
const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
expect(encUndo.length).toBeGreaterThan(0);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeathSave -> Firebase', () => {
|
||||
test('first death save: updateDoc increments deathSaves', async () => {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
|
||||
const { within } = require('@testing-library/react');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('DSC2');
|
||||
await selectCampaignByName('DSC2');
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } });
|
||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => {
|
||||
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
|
||||
return c;
|
||||
});
|
||||
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
|
||||
|
||||
await createEncounterViaUI('DSEnc2');
|
||||
await selectEncounterByName('DSEnc2');
|
||||
// switch to character type and add
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Hero');
|
||||
|
||||
await startCombatViaUI();
|
||||
// damage to 0
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
|
||||
// death save buttons appear
|
||||
const save1 = screen.getByTitle('Success 1');
|
||||
fireEvent.click(save1);
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
|
||||
expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
|
||||
describe('DeathSave -> Firebase/UI', () => {
|
||||
test('drop to 0 shows death-save controls and status dying', async () => {
|
||||
await addCharacterToEncounter('Hero', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dying');
|
||||
expect(lastEncCall().data.participants[0].status).toBe('dying');
|
||||
expect(screen.getByRole('button', { name: /^Fail$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Success$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('third death save: marks isDying true', async () => {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers');
|
||||
const { within } = require('@testing-library/react');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('DSDie');
|
||||
await selectCampaignByName('DSDie');
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } });
|
||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => {
|
||||
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
|
||||
return c;
|
||||
});
|
||||
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
|
||||
test('third Fail action immediately shows Dead and keeps participant', async () => {
|
||||
await addCharacterToEncounter('Martyr', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.deathSaveFailures).toBe(0);
|
||||
expect(lastEncCall().data.participants).toHaveLength(1);
|
||||
expect(screen.getAllByText('Martyr').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('third Success action immediately shows Stable and hides controls', async () => {
|
||||
await addCharacterToEncounter('StableHero', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 2);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
||||
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'stable');
|
||||
expect(lastEncCall().data.participants[0].deathSaveSuccesses).toBe(0);
|
||||
expect(screen.getAllByText(/Stable/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('button', { name: /^Success$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Nat20 restores 1 HP conscious and hides death-save UI', async () => {
|
||||
await addCharacterToEncounter('Lucky', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nat20/i }));
|
||||
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'conscious');
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(1);
|
||||
expect(p.deathSaveSuccesses).toBe(0);
|
||||
expect(p.deathSaveFailures).toBe(0);
|
||||
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('dead character remains excluded from add dropdown because still in encounter', async () => {
|
||||
const { getParticipantForm } = require('./testHelpers');
|
||||
await addCharacterToEncounter('StillHere', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
||||
|
||||
await createEncounterViaUI('DSEncDie');
|
||||
await selectEncounterByName('DSEncDie');
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Martyr');
|
||||
|
||||
await startCombatViaUI();
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Fail 1'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 1);
|
||||
fireEvent.click(screen.getByTitle('Fail 2'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 2);
|
||||
fireEvent.click(screen.getByTitle('Fail 3'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
|
||||
expect(lastEncCall().data.participants[0].isDying).toBe(true);
|
||||
expect(lastEncCall().data.participants[0].deathFails).toBe(3);
|
||||
expect(screen.getAllByText('StillHere').length).toBeGreaterThan(0);
|
||||
expect(form.queryByRole('option', { name: 'StillHere' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,9 @@ describe('Participant -> Firebase', () => {
|
||||
const p = call.data.participants[0];
|
||||
expect(p).toMatchObject({
|
||||
name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7,
|
||||
isNpc: false, isActive: true, deathSaves: 0, isDying: false, conditions: [],
|
||||
isActive: true, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0, conditions: [],
|
||||
});
|
||||
expect(p).not.toHaveProperty('isNpc');
|
||||
expect(p).toHaveProperty('id');
|
||||
expect(p).toHaveProperty('initiative');
|
||||
});
|
||||
@@ -43,7 +44,7 @@ describe('Participant -> Firebase', () => {
|
||||
expect(p.initiative).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
test('addMonster as NPC: isNpc true', async () => {
|
||||
test('addMonster as NPC: type npc', async () => {
|
||||
await setupReady();
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
|
||||
@@ -53,7 +54,8 @@ describe('Participant -> Firebase', () => {
|
||||
const p = lastEncCall()?.data?.participants?.[0];
|
||||
return p && p.name === 'Guard';
|
||||
});
|
||||
expect(lastEncCall().data.participants[0].isNpc).toBe(true);
|
||||
expect(lastEncCall().data.participants[0].type).toBe('npc');
|
||||
expect(lastEncCall().data.participants[0]).not.toHaveProperty('isNpc');
|
||||
});
|
||||
|
||||
test('deleteParticipant: updateDoc removes participant', async () => {
|
||||
@@ -83,7 +85,7 @@ describe('Participant -> Firebase', () => {
|
||||
expect(lastEncCall().data.participants[0].currentHp).toBe(7);
|
||||
});
|
||||
|
||||
test('damage to 0 deactivates participant', async () => {
|
||||
test('damage to 0 marks non-NPC monster dead and inactive', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Doom', 5, 0);
|
||||
await startCombatViaUI();
|
||||
@@ -92,10 +94,11 @@ describe('Participant -> Firebase', () => {
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('heal revives from 0 (reactivates, resets death saves)', async () => {
|
||||
test('normal heal does not revive dead non-NPC monster', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Revive', 5, 0);
|
||||
await startCombatViaUI();
|
||||
@@ -104,11 +107,10 @@ describe('Participant -> Firebase', () => {
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
|
||||
fireEvent.click(screen.getByTitle(/Heal/i));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 3);
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(3);
|
||||
expect(p.isActive).toBe(true);
|
||||
expect(p.deathSaves).toBe(0);
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('dead');
|
||||
});
|
||||
|
||||
test('toggleCondition: updateDoc adds condition to array', async () => {
|
||||
|
||||
Reference in New Issue
Block a user