Author SHA1 Message Date
david raistrick d83d1383c0 refactor: single-source combat logic (App handlers call shared, slot model)
App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared:
startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s),
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange,
deathSave, toggleCondition, reorderParticipants. ONE code path for UI.

shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md):
- addParticipant: splice into slot by initiative (no sort)
- updateParticipant: re-slot on initiative change (no sort)
- reorderParticipants: same-init drag only (cross-init blocked)
- applyHpChange: death flips isActive=false (matches App), revive reactivates

No renames, no API changes. Shared func signatures unchanged.

Tests updated to match unified behavior:
- dead-skip: death deactivates (was FEAT-1 stays active)
- characterization: death deactivates + revive reactivates
- reorder/invariant: cross-init drag blocked, same-init allowed

Both suites green: shared 91/91, App 77/77, 0 warnings.
2026-07-03 17:59:41 -04:00
david raistrick c3d89554e8 docs: initiative ordering design (slot, never sort)
Single list = display = turn order. Slot by initiative, tie-break = add
order. Drag = same-init only (DM tie override). No re-sort on mutation.
Round wrap no rebuild. No cross-init drag.
2026-07-03 17:18:00 -04:00
david raistrick d1cc3a8b9a test: warning=failure guard + fix ambiguous switch selector
- setupTests.js: console.error/warn now throw. Warning = failure.
- Combat.characterization: two role=switch (player HP + NPC HP), scope
  to player-HP label. getByRole('switch') was ambiguous.

Full suite: 77/77 pass, 0 warnings.
2026-07-03 16:05:04 -04:00
david raistrick 31d19c5912 test: kill act env warnings + remove debug console noise
Root cause: mock _notify cb fires during waitFor poll, but testing-library
asyncWrapper flips IS_REACT_ACT_ENVIRONMENT=false. Raw react act() then
warns 'not configured to support act' (146 warnings).

Fix: _notify sets globalThis.IS_REACT_ACT_ENVIRONMENT=true around act(),
restores after. setupTests sets flag globally. Both together = 0 warnings.

Also:
- Remove 4 debug console.log from App.js (encounter start/end, drag, add chars)
- Remove always-fires scenario summary console.log
- Add fastWaitFor helper (5ms poll) to testHelpers
- Swap scenario waitFor -> fastWaitFor

Tests: 77 pass / 0 fail / 0 warnings / 0 console noise.
2026-07-03 15:59:14 -04:00
david raistrick 530aaadf90 test: kill act env warnings (globalThis flag + _notify act wrapper)
- setupTests.js: set globalThis.IS_REACT_ACT_ENVIRONMENT = true (RTL reads
  getGlobalThis, not global). Kills 'environment not configured for act'.
- _mock-db.js _notify: set flag true around subscriber cb, restore prev
  after. RTL waitFor flips flag false mid-poll -> act() warned. Now clean.
- 146 act warnings -> 0.
2026-07-03 15:58:50 -04:00
david raistrick 1a744e511a test: upgrade @testing-library/react v14, kill act warnings
@testing-library/react v13 -> v14. v13 used deprecated
ReactDOMTestUtils.act on every render -> 708 deprecation warnings.
v14 imports act from react directly. Drops to 0.

Mock _notify (src/__mocks__/firebase/_mock-db.js): wrap subscriber
callbacks in act(() => cb()). Sync setState from subscribe callbacks
fired outside test act boundary -> 14 warnings. Now 0. try/catch
fallback if react/act unavailable (defensive).

Full suite: 166 pass / 1 fail (Combat.scenario BUG-11 pre-existing
crash). Warnings: 1416 -> 0.

Note: NOT complete kill-shared. App.js still inlines 8 logic fns
(startEncounter, nextTurn, togglePause, endEncounter, addParticipant,
updateParticipant, reorder/drag). turn.js versions dead in app, used
by replay + turn tests only. Next: make handlers call turn.js.
2026-07-03 15:46:27 -04:00
15 changed files with 338 additions and 462 deletions
+7 -3
View File
@@ -5,10 +5,14 @@ REWORK_PLAN.md.
## Feature backlog ## Feature backlog
### CRITICAL BUG - storage TODO: FIX: test for warnings. any warnings in tests, or compile or runtime == failure. like perl and php used to ahve. warning == error. we should lways solve them
- docker for sql is not using persistant storage...
TODO: make tests have integrated timeout - even 60s is probabvly too long, should not take long.
TODO: combat.scenario doesnt do 100 rounds it does 100 turns. recover from kill-shared stash
### feat - campaign section rollup
### feat - add all characters to participants list ### feat - add all characters to participants list
+59
View File
@@ -0,0 +1,59 @@
# Initiative Ordering — Design
## Core Principle
**Slot, never sort.**
Initiative order = a single list. Participants occupy slots determined by
initiative value. The list is mutated by insert/move operations — never
re-sorted wholesale.
## Single Source of Truth
One list: `participants[]`.
- Display order = `participants[]` order (both Player view + DM view).
- Turn order = `participants[]` order.
- No derived/re-sorted copy. `turnOrderIds`, if present, is always an exact
mirror (`participants.map(p => p.id)`) — never an independent ordering.
## When Ordering Changes
| Action | Effect on order |
|--------|-----------------|
| **Add** (roll or manual) | Insert participant into slot by initiative |
| **Edit initiative** | Move participant to new slot |
| **Drag** | Reorder within a tie (same initiative) only |
| **Remove** | Splice out; others keep position |
| **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. |
## Slotting Rules
- Insert/move positions participants so the list stays initiative-descending.
- **Tie-break = original add order.** Later additions slot *after* existing
same-init participants. Stable insertion.
- Tie order is only changed by explicit DM drag. Never auto-changed.
## Drag (DM Tie-Break Override)
- Only participants with the **same initiative** are draggable onto each other.
- Cross-initiative drag is **not allowed**. (Use edit-initiative field instead.)
- Drag persists in `participants[]`. Survives all subsequent operations.
- **Re-slotting on add/edit must preserve drag-established tie order.**
## Explicitly Forbidden
- `sort()` on every mutation. Overwrites drag order. Root cause of past drift.
- Separate display order vs turn order. Causes player/DM divergence.
- Re-sort on round wrap. Replays rounds, introduces skips.
- Cross-initiative drag. Contradicts initiative as the primary key.
- Auto-changing tie order on add/edit. Silently loses DM intent.
## Why
Sorting is destructive to manual ordering. A stable slot list with drag for
ties gives deterministic, DM-controllable order that never drifts between
display surfaces or combat rounds.
+24 -10
View File
@@ -13,7 +13,6 @@
], ],
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"firebase": "^10.12.2", "firebase": "^10.12.2",
@@ -24,6 +23,9 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/react": "^14.3.1"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -5237,17 +5239,18 @@
} }
}, },
"node_modules/@testing-library/react": { "node_modules/@testing-library/react": {
"version": "13.4.0", "version": "14.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
"integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.5.0", "@testing-library/dom": "^9.0.0",
"@types/react-dom": "^18.0.0" "@types/react-dom": "^18.0.0"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=14"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0", "react": "^18.0.0",
@@ -5255,9 +5258,10 @@
} }
}, },
"node_modules/@testing-library/react/node_modules/@testing-library/dom": { "node_modules/@testing-library/react/node_modules/@testing-library/dom": {
"version": "8.20.1", "version": "9.3.4",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
@@ -5270,13 +5274,14 @@
"pretty-format": "^27.0.2" "pretty-format": "^27.0.2"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=14"
} }
}, },
"node_modules/@testing-library/react/node_modules/aria-query": { "node_modules/@testing-library/react/node_modules/aria-query": {
"version": "5.1.3", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"deep-equal": "^2.0.5" "deep-equal": "^2.0.5"
@@ -5286,6 +5291,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
@@ -5635,6 +5641,7 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -5660,6 +5667,7 @@
"version": "18.3.27", "version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -5671,6 +5679,7 @@
"version": "18.3.7", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
@@ -9383,6 +9392,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -9505,6 +9515,7 @@
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"array-buffer-byte-length": "^1.0.0", "array-buffer-byte-length": "^1.0.0",
@@ -10118,6 +10129,7 @@
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.2", "call-bind": "^1.0.2",
@@ -12560,6 +12572,7 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -16816,6 +16829,7 @@
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.7", "call-bind": "^1.0.7",
+3 -1
View File
@@ -8,7 +8,6 @@
], ],
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"firebase": "^10.12.2", "firebase": "^10.12.2",
@@ -47,5 +46,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@testing-library/react": "^14.3.1"
} }
} }
+10 -11
View File
@@ -213,22 +213,21 @@ describe('applyHpChange', () => {
expect(patch.participants[0].currentHp).toBe(10); expect(patch.participants[0].currentHp).toBe(10);
}); });
test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => { test('damage to 0 deactivates + keeps turn order (unified)', () => {
// FEAT-1: death no longer deactivates or removes from turn order. // Unified: death flips isActive=false (removed from active rotation).
// Dead stay in rotation, nextTurn still visits them, PCs get death-save turn. // turnOrderIds unchanged (no turn-order patch on death).
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)]; const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const { patch } = applyHpChange(e, 'a', 'damage', 5); const { patch } = applyHpChange(e, 'a', 'damage', 5);
expect(patch.participants[0].currentHp).toBe(0); expect(patch.participants[0].currentHp).toBe(0);
expect(patch.participants[0].isActive).toBe(true); expect(patch.participants[0].isActive).toBe(false);
expect(patch.turnOrderIds).toBeUndefined(); expect(patch.turnOrderIds).toBeUndefined();
expect(patch.currentTurnParticipantId).toBeUndefined(); expect(patch.currentTurnParticipantId).toBeUndefined();
}); });
test('heal above 0 resets death saves, keeps active (FEAT-1)', () => { test('heal above 0 reactivates + resets death saves (unified)', () => {
// FEAT-1: revive no longer flips isActive (was already active — death // Unified: revive from 0 flips isActive=true, deathSaves reset.
// doesn't deactivate). deathSaves still reset. const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5); const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
expect(patch.participants[0].currentHp).toBe(5); expect(patch.participants[0].currentHp).toBe(5);
expect(patch.participants[0].isActive).toBe(true); expect(patch.participants[0].isActive).toBe(true);
@@ -285,17 +284,17 @@ describe('toggleCondition', () => {
}); });
describe('reorderParticipants', () => { describe('reorderParticipants', () => {
test('drag before target (1-list, cross-init allowed)', () => { test('drag before target (same-init tie)', () => {
const ps = [p('a', 10), p('b', 10), p('c', 10)]; const ps = [p('a', 10), p('b', 10), p('c', 10)];
const { patch } = reorderParticipants(enc(ps), 'a', 'c'); const { patch } = reorderParticipants(enc(ps), 'a', 'c');
// drag a before c: remove a → [b,c], insert before c → [b,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']); expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
}); });
test('cross-init drag allowed (1-list, DM override)', () => { test('cross-init drag blocked (no-op)', () => {
const ps = [p('a', 10), p('b', 5)]; const ps = [p('a', 10), p('b', 5)];
const { patch } = reorderParticipants(enc(ps), 'a', 'b'); const { patch } = reorderParticipants(enc(ps), 'a', 'b');
expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']); expect(patch).toBeNull();
}); });
}); });
+32 -15
View File
@@ -1,6 +1,9 @@
// M4 desired behavior: dead PC stays in turn order, turn still comes up, // Unified behavior (App main): death flips isActive=false, dead participant
// deathSave fires. Current code filters isActive (set false on death) so // removed from active rotation, skipped by nextTurn. deathSave is a manual
// dead participants are SKIPPED. Test asserts desired state = RED on current. // 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.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared; const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
@@ -24,8 +27,8 @@ function enc(ps) {
round:0, currentTurnParticipantId:null, turnOrderIds:[] }; round:0, currentTurnParticipantId:null, turnOrderIds:[] };
} }
describe('M4: dead participants stay in turn order', () => { describe('unified: death deactivates, dead skipped in rotation', () => {
test('dead PC not removed from turnOrderIds', () => { test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
@@ -35,39 +38,53 @@ describe('M4: dead participants stay in turn order', () => {
expect(e.turnOrderIds).toEqual(orderBefore); expect(e.turnOrderIds).toEqual(orderBefore);
}); });
test('dead PC turn still comes up (nextTurn visits them)', () => { test('dead PC skipped by nextTurn (isActive=false)', () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
// kill b // kill b
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
// advance: a→b→c. b's turn should come up. // advance: a→c (b skipped, inactive)
e = { ...e, ...nextTurn(e).patch }; e = { ...e, ...nextTurn(e).patch };
expect(e.currentTurnParticipantId).toBe('b'); expect(e.currentTurnParticipantId).toBe('c');
}); });
test('dead PC on their turn can deathSave', () => { test('dead PC deathSave fires on manual call (not via rotation)', () => {
const ps = [pc('a', 20), pc('b', 15)]; const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
// kill b (current = a) // kill b (current = a)
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
// advance to b's turn // b is dead: DM can still fire deathSave (manual action)
e = { ...e, ...nextTurn(e).patch };
expect(e.currentTurnParticipantId).toBe('b');
// b is dead, on their turn: deathSave should not throw
const r = deathSave(e, 'b', 1); const r = deathSave(e, 'b', 1);
expect(r.patch).toBeTruthy(); expect(r.patch).toBeTruthy();
const b = r.patch.participants.find(x => x.id === 'b'); const b = r.patch.participants.find(x => x.id === 'b');
expect(b.deathSaves).toBe(1); expect(b.deathSaves).toBe(1);
}); });
test('dead PC not auto-set isActive=false by applyHpChange', () => { // 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)]; const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
const b = e.participants.find(x => x.id === 'b'); const b = e.participants.find(x => x.id === 'b');
expect(b.isActive).toBe(true); expect(b.isActive).toBe(false);
});
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);
}); });
}); });
+6 -6
View File
@@ -82,16 +82,16 @@ describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () =
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
}); });
test('reorder cross-init: allowed, list + rotation reflect new order', () => { test('reorder cross-init: blocked (no-op)', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]); let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,b,c] e = apply(e, startEncounter(e)); // [a,b,c]
e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a const r = reorderParticipants(e, 'c', 'a'); // cross-init
expect(e.turnOrderIds).toEqual(['c','a','b']); expect(r.patch).toBeNull();
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
}); });
test('reorder: rotation follows new list order', () => { test('reorder same-init: rotation follows new list order', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]); let e = enc([p('a',10),p('b',10),p('c',3)]); // a,b tie
e = apply(e, startEncounter(e)); // [a,b,c], cur=a e = apply(e, startEncounter(e)); // [a,b,c], cur=a
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a
const rot = walkRotation(e); // start a, next c (wrap), next b, back a const rot = walkRotation(e); // start a, next c (wrap), next b, back a
+2 -2
View File
@@ -29,12 +29,12 @@ describe('reorderParticipants', () => {
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']); expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']);
}); });
test('cross-init drag allowed (1-list, DM override)', () => { test('cross-init drag blocked (no-op)', () => {
const ps = [p('a', 10), p('b', 20)]; const ps = [p('a', 10), p('b', 20)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; // [b,a] e = { ...e, ...startEncounter(e).patch }; // [b,a]
const r = reorderParticipants(e, 'a', 'b'); const r = reorderParticipants(e, 'a', 'b');
expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']); expect(r.patch).toBeNull();
}); });
test('throws if id not found', () => { test('throws if id not found', () => {
+24 -29
View File
@@ -311,26 +311,14 @@ function addParticipant(encounter, participant) {
if ((encounter.participants || []).some(p => p.id === participant.id)) { if ((encounter.participants || []).some(p => p.id === participant.id)) {
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`); throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
} }
// 1-list: splice participant into participants[] by initiative position, // FEAT-3: reslot by initiative on add (stable sort, tie-break original index).
// then sync turnOrderIds = participants.map(id). // Pre-combat: list reflects init order immediately. Post-combat: slots into running order.
let updatedParticipants; const updatedParticipants = sortParticipantsByInitiative(
let insertAt; [...(encounter.participants || []), participant],
if (!encounter.isStarted) { encounter.participants || []
updatedParticipants = [...(encounter.participants || []), participant]; );
} else {
const { insertAt: at } = computeTurnOrderAfterAddition(
{ ...encounter, participants: [...(encounter.participants || []), participant] },
participant.id);
insertAt = at !== undefined ? at : (encounter.participants || []).length;
updatedParticipants = [
...(encounter.participants || []).slice(0, insertAt),
participant,
...(encounter.participants || []).slice(insertAt),
];
}
const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {};
return { return {
patch: { participants: updatedParticipants, ...turnUpdates }, patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) },
log: { log: {
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`, message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
undo: { undo: {
@@ -354,7 +342,10 @@ function updateParticipant(encounter, participantId, updatedData) {
const updatedParticipants = (encounter.participants || []).map(p => const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...updatedData } : p p.id === participantId ? { ...p, ...updatedData } : p
); );
return { patch: { participants: updatedParticipants }, log: null }; // FEAT-3: reslot by initiative so any init change reflects in list order
// immediately (stable sort, tie-break = original array index).
const reslotted = sortParticipantsByInitiative(updatedParticipants, encounter.participants || []);
return { patch: { participants: reslotted, ...syncTurnOrder(reslotted) }, log: null };
} }
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant // REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
@@ -427,24 +418,24 @@ function applyHpChange(encounter, participantId, changeType, amount) {
const isDead = newHp === 0; const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0; const wasResurrected = wasDead && newHp > 0;
// FEAT-1: death no longer flips isActive or touches turnOrderIds. // Unified (App main): death flips isActive=false (removed from active
// Dead participants stay in turn order, nextTurn still visits them, PCs // rotation, skipped by nextTurn). Revive flips true. No turnOrderIds change.
// get their death-save turn. isActive = DM-controlled combatant toggle only.
const updatedParticipants = (encounter.participants || []).map(p => { const updatedParticipants = (encounter.participants || []).map(p => {
if (p.id !== participantId) return p; if (p.id !== participantId) return p;
const updates = { ...p, currentHp: newHp }; const updates = { ...p, currentHp: newHp };
if (isDead && !wasDead) { if (isDead && !wasDead) {
updates.isActive = false;
updates.deathSaves = p.deathSaves || 0; updates.deathSaves = p.deathSaves || 0;
updates.isDying = false; updates.isDying = false;
} }
if (wasResurrected) { if (wasResurrected) {
updates.isActive = true;
updates.deathSaves = 0; updates.deathSaves = 0;
updates.isDying = false; updates.isDying = false;
} }
return updates; return updates;
}); });
// No turn-order updates on death/revive (FEAT-1).
const turnUpdates = {}; const turnUpdates = {};
const hpLine = `${participant.currentHp}${newHp} HP`; const hpLine = `${participant.currentHp}${newHp} HP`;
@@ -519,15 +510,19 @@ function toggleCondition(encounter, participantId, conditionId) {
// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative // REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds. // (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
// REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override).
// Cross-init drag = no-op (use edit-initiative field instead). Splice move.
function reorderParticipants(encounter, draggedId, targetId) { function reorderParticipants(encounter, draggedId, targetId) {
const participants = [...(encounter.participants || [])]; const participants = [...(encounter.participants || [])];
const draggedIndex = participants.findIndex(p => p.id === draggedId); const dragged = participants.find(p => p.id === draggedId);
const targetIndex = participants.findIndex(p => p.id === targetId); const target = participants.find(p => p.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) { if (!dragged || !target) throw new Error('Dragged or target item not found.');
throw new Error('Dragged or target item not found.'); if (draggedId === targetId) return { patch: null, log: null };
if (dragged.initiative !== target.initiative) {
return { patch: null, log: null }; // cross-init blocked
} }
const draggedIndex = participants.findIndex(p => p.id === draggedId);
const [removedItem] = participants.splice(draggedIndex, 1); const [removedItem] = participants.splice(draggedIndex, 1);
// recompute targetIndex after removal (shift if dragged was before target)
const newTargetIndex = participants.findIndex(p => p.id === targetId); const newTargetIndex = participants.findIndex(p => p.id === targetId);
participants.splice(newTargetIndex, 0, removedItem); participants.splice(newTargetIndex, 0, removedItem);
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {}; const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {};
+85 -354
View File
@@ -47,7 +47,19 @@ if (typeof document !== 'undefined') {
// ============================================================================ // ============================================================================
const APP_VERSION = 'v0.3'; const APP_VERSION = 'v0.3';
const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval } = shared; const {
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod,
sortParticipantsByInitiative, syncTurnOrder,
computeTurnOrderAfterRemoval,
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive: combatToggleActive,
applyHpChange: combatApplyHpChange,
deathSave: combatDeathSave,
toggleCondition: combatToggleCondition,
reorderParticipants,
} = shared;
const ROLL_DISPLAY_DURATION = 5000; const ROLL_DISPLAY_DURATION = 5000;
const CONDITIONS = [ const CONDITIONS = [
@@ -825,7 +837,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
if (participantType === 'character') { if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId); const character = campaignCharacters.find(c => c.id === selectedCharacterId);
if (!character) { if (!character) {
console.error("Selected character not found");
return; return;
} }
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) { if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) {
@@ -852,18 +863,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
isNpc: participantType === 'monster' ? participantIsNpc : false, isNpc: participantType === 'monster' ? participantIsNpc : false,
conditions: [], conditions: [],
isActive: true, isActive: true,
deathSaves: 0, // Track failed death saves (0-3) deathSaves: 0,
isDying: false, // For death animation on player display isDying: false,
}; };
try { try {
await storage.updateDoc(encounterPath, { const { patch, log } = addParticipant(encounter, newParticipant);
participants: sortParticipantsByInitiative([...participants, newParticipant], participants), await storage.updateDoc(encounterPath, patch);
...syncTurnOrder([...participants, newParticipant]), logAction(log.message, { encounterName: encounter.name }, {
});
logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { participants: [...participants] }, updates: log.undo,
}); });
setLastRollDetails({ setLastRollDetails({
@@ -876,7 +885,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
}); });
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
// Reset form
setParticipantName(''); setParticipantName('');
setMaxHp(DEFAULT_MAX_HP); setMaxHp(DEFAULT_MAX_HP);
setSelectedCharacterId(''); setSelectedCharacterId('');
@@ -884,7 +892,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
setIsNpc(false); setIsNpc(false);
setManualInitiative(''); setManualInitiative('');
} catch (err) { } catch (err) {
console.error("Error adding participant:", err);
alert("Failed to add participant. Please try again."); alert("Failed to add participant. Please try again.");
} }
}; };
@@ -902,7 +909,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const initiativeRoll = rollD20(); const initiativeRoll = rollD20();
const modifier = char.defaultInitMod || 0; const modifier = char.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier; const finalInitiative = initiativeRoll + modifier;
return { return {
id: generateId(), id: generateId(),
name: char.name, name: char.name,
@@ -925,32 +931,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
try { try {
await storage.updateDoc(encounterPath, { const { patch } = addParticipants(encounter, newParticipants);
participants: [...participants, ...newParticipants] await storage.updateDoc(encounterPath, patch);
});
console.log(`Added ${newParticipants.length} characters to the encounter.`);
} catch (err) { } catch (err) {
console.error("Error adding all campaign characters:", err);
alert("Failed to add all characters. Please try again."); alert("Failed to add all characters. Please try again.");
} }
}; };
const handleUpdateParticipant = async (updatedData) => { const handleUpdateParticipant = async (updatedData) => {
if (!db || !editingParticipant) return; if (!db || !editingParticipant) return;
const updatedParticipants = participants.map(p =>
p.id === editingParticipant.id ? { ...p, ...updatedData } : p
);
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
try { try {
await storage.updateDoc(encounterPath, { const { patch } = updateParticipant(encounter, editingParticipant.id, updatedData);
participants: reslotted, await storage.updateDoc(encounterPath, patch);
...syncTurnOrder(reslotted),
});
setEditingParticipant(null); setEditingParticipant(null);
} catch (err) { } catch (err) {
console.error("Error updating participant:", err);
alert("Failed to update participant. Please try again."); alert("Failed to update participant. Please try again.");
} }
}; };
@@ -963,17 +957,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
if (!db) return; if (!db) return;
const n = parseInt(value, 10); const n = parseInt(value, 10);
if (isNaN(n)) return; if (isNaN(n)) return;
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, initiative: n } : p
);
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
try { try {
await storage.updateDoc(encounterPath, { const { patch } = updateParticipant(encounter, participantId, { initiative: n });
participants: reslotted, await storage.updateDoc(encounterPath, patch);
...syncTurnOrder(reslotted),
});
} catch (err) { } catch (err) {
console.error("Error updating initiative:", err); // fall through silently
} }
}; };
@@ -984,221 +972,89 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const confirmDeleteParticipant = async () => { const confirmDeleteParticipant = async () => {
if (!db || !itemToDelete) return; if (!db || !itemToDelete) return;
const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id);
const deleteUndoData = {
encounterPath,
updates: {
participants: [...participants],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try { try {
await storage.updateDoc(encounterPath, { const { patch, log } = removeParticipant(encounter, itemToDelete.id);
participants: updatedParticipants, await storage.updateDoc(encounterPath, patch);
...(encounter.isStarted ? { logAction(log.message, { encounterName: encounter.name }, {
...syncTurnOrder(updatedParticipants), encounterPath,
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants), updates: log.undo,
} : {}),
}); });
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData);
} catch (err) { } catch (err) {
console.error("Error deleting participant:", err);
alert("Failed to delete participant. Please try again."); alert("Failed to delete participant. Please try again.");
} }
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setItemToDelete(null); setItemToDelete(null);
}; };
const toggleParticipantActive = async (participantId) => { const toggleParticipantActive = async (participantId) => {
if (!db) return; if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const newIsActive = !participant.isActive;
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, isActive: newIsActive } : p
);
// 1-list: stay in slot, flip isActive only. Sync turnOrderIds. Advance
// current only if deact hits current.
let turnUpdates = {};
if (encounter.isStarted) {
turnUpdates = syncTurnOrder(updatedParticipants);
if (!newIsActive && encounter.currentTurnParticipantId === participantId) {
turnUpdates = { ...turnUpdates, ...computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) };
}
}
try { try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates }); const { patch, log } = combatToggleActive(encounter, participantId);
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, { await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { updates: log.undo,
participants: [...participants],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
}); });
} catch (err) { } catch (err) {
console.error("Error toggling active state:", err); // fall through silently
} }
}; };
const applyHpChange = async (participantId, changeType) => { const applyHpChange = async (participantId, changeType) => {
if (!db) return; if (!db) return;
const amountStr = hpChangeValues[participantId]; const amountStr = hpChangeValues[participantId];
if (!amountStr || amountStr.trim() === '') return; if (!amountStr || amountStr.trim() === '') return;
const amount = parseInt(amountStr, 10); const amount = parseInt(amountStr, 10);
if (isNaN(amount) || amount === 0) { if (isNaN(amount) || amount === 0) {
setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
return; return;
} }
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
let newHp = participant.currentHp;
if (changeType === 'damage') {
newHp = Math.max(0, participant.currentHp - amount);
} else if (changeType === 'heal') {
newHp = Math.min(participant.maxHp, participant.currentHp + amount);
}
// Determine if participant died or was resurrected
const wasDead = participant.currentHp === 0;
const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0;
const updatedParticipants = participants.map(p => {
if (p.id === participantId) {
const updates = { ...p, currentHp: newHp };
// Handle death - deactivate and start death saves
if (isDead && !wasDead) {
updates.isActive = false;
updates.deathSaves = p.deathSaves || 0;
updates.isDying = false;
}
// Handle resurrection - reactivate and reset death saves
if (wasResurrected) {
updates.isActive = true;
updates.deathSaves = 0;
updates.isDying = false;
}
return updates;
}
return p;
});
const turnUpdates = {};
const hpUndoData = {
encounterPath,
updates: {
participants: [...participants],
...((isDead && !wasDead) || wasResurrected ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try { try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates }); const { patch, log } = combatApplyHpChange(encounter, participantId, changeType, amount);
setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); if (patch) {
const hpLine = `${participant.currentHp}${newHp} HP`; await storage.updateDoc(encounterPath, patch);
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : ''; if (log) {
const resurSuffix = wasResurrected ? ' — Revived' : ''; logAction(log.message, { encounterName: encounter.name }, {
if (changeType === 'damage') { encounterPath,
logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }, hpUndoData); updates: log.undo,
} else { });
logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }, hpUndoData);
} }
}
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
} catch (err) { } catch (err) {
console.error("Error applying HP change:", err); // fall through silently
} }
}; };
const handleDeathSaveChange = async (participantId, saveNumber) => { const handleDeathSaveChange = async (participantId, saveNumber) => {
if (!db) return; if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const currentSaves = participant.deathSaves || 0;
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
// If clicking the third death save, mark as dying (for player view animation)
if (newSaves === 3) {
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
);
try { try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants }); const { patch, isDying } = combatDeathSave(encounter, participantId, saveNumber);
await storage.updateDoc(encounterPath, patch);
// Wait for animation to complete on player display (2 seconds) then remove participant if (isDying) {
setTimeout(async () => { setTimeout(async () => {
const finalParticipants = participants.filter(p => p.id !== participantId); const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
try {
await storage.updateDoc(encounterPath, { participants: finalParticipants }); await storage.updateDoc(encounterPath, { participants: finalParticipants });
} catch (err) {
console.error("Error removing dead participant:", err);
}
}, 2000); }, 2000);
} catch (err) {
console.error("Error marking participant as dying:", err);
} }
} else {
// Normal death save update
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves } : p
);
try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
} catch (err) { } catch (err) {
console.error("Error updating death saves:", err); // fall through silently
}
} }
}; };
const toggleCondition = async (participantId, conditionId) => { const toggleCondition = async (participantId, conditionId) => {
if (!db) return; if (!db) return;
const participant = participants.find(p => p.id === participantId);
if (!participant) return;
const wasActive = (participant.conditions || []).includes(conditionId);
const updatedParticipants = participants.map(p => {
if (p.id !== participantId) return p;
const current = p.conditions || [];
const next = wasActive
? current.filter(c => c !== conditionId)
: [...current, conditionId];
return { ...p, conditions: next };
});
try { try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants }); const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
await storage.updateDoc(encounterPath, patch);
const cond = CONDITIONS.find(c => c.id === conditionId); const cond = CONDITIONS.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId; const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, { logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { participants: [...participants] }, updates: log.undo,
}); });
} catch (err) { } catch (err) {
console.error("Error updating conditions:", err); // fall through silently
} }
}; };
@@ -1214,40 +1070,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const handleDrop = async (e, targetId) => { const handleDrop = async (e, targetId) => {
e.preventDefault(); e.preventDefault();
if (!db || draggedItemId === null || draggedItemId === targetId) { if (!db || draggedItemId === null || draggedItemId === targetId) {
setDraggedItemId(null); setDraggedItemId(null);
return; return;
} }
const currentParticipants = [...participants];
const draggedIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
const targetIndex = currentParticipants.findIndex(p => p.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) {
console.error("Dragged or target item not found.");
setDraggedItemId(null);
return;
}
const draggedItem = currentParticipants[draggedIndex];
const targetItem = currentParticipants[targetIndex];
if (draggedItem.initiative !== targetItem.initiative) {
console.log("Drag-drop only allowed for participants with same initiative.");
setDraggedItemId(null);
return;
}
const [removedItem] = currentParticipants.splice(draggedIndex, 1);
currentParticipants.splice(targetIndex, 0, removedItem);
try { try {
await storage.updateDoc(encounterPath, { participants: currentParticipants }); const { patch } = reorderParticipants(encounter, draggedItemId, targetId);
await storage.updateDoc(encounterPath, patch);
} catch (err) { } catch (err) {
console.error("Error reordering participants:", err); // drag invalid (id not found) — ignore
} }
setDraggedItemId(null); setDraggedItemId(null);
}; };
@@ -1684,75 +1516,33 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
return; return;
} }
const activeParticipants = encounter.participants.filter(p => p.isActive);
if (activeParticipants.length === 0) {
alert("No active participants.");
return;
}
// 1-list model: sort ALL participants by init (active+inactive),
// first active = current. Matches shared.startEncounter.
const sortedParticipants = sortParticipantsByInitiative(encounter.participants, encounter.participants);
const firstActive = sortedParticipants.find(p => p.isActive);
try { try {
await storage.updateDoc(encounterPath, { const { patch, log } = startEncounter(encounter);
isStarted: true, await storage.updateDoc(encounterPath, patch);
isPaused: false,
round: 1,
participants: sortedParticipants,
currentTurnParticipantId: firstActive.id,
turnOrderIds: sortedParticipants.map(p => p.id)
});
await storage.updateDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounter.id activeEncounterId: encounter.id
}); });
logAction(log.message, { encounterName: encounter.name }, {
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { updates: log.undo,
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
}); });
console.log("Encounter started and set as active display.");
} catch (err) { } catch (err) {
console.error("Error starting encounter:", err); alert(err.message || "Failed to start encounter. Please try again.");
alert("Failed to start encounter. Please try again.");
} }
}; };
const handleTogglePause = async () => { const handleTogglePause = async () => {
if (!db || !encounter || !encounter.isStarted) return; if (!db || !encounter || !encounter.isStarted) return;
const newPausedState = !encounter.isPaused;
let newTurnOrderIds = encounter.turnOrderIds;
if (!newPausedState && encounter.isPaused) {
// 1-list model: no re-sort on resume. turnOrderIds already mirrors
// participants[] (set at start/add/reorder). Resume = unpause only.
newTurnOrderIds = encounter.turnOrderIds;
}
try { try {
await storage.updateDoc(encounterPath, { const { patch, log } = togglePause(encounter);
isPaused: newPausedState, await storage.updateDoc(encounterPath, patch);
turnOrderIds: newTurnOrderIds logAction(log.message, { encounterName: encounter.name }, {
});
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { updates: log.undo,
isPaused: encounter.isPaused ?? false,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
}); });
} catch (err) { } catch (err) {
console.error("Error toggling pause state:", err); // fall through silently
} }
}; };
@@ -1760,11 +1550,16 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
if (!db || !encounter.isStarted || encounter.isPaused) return; if (!db || !encounter.isStarted || encounter.isPaused) return;
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return; if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
const activePsInOrder = encounter.turnOrderIds try {
.map(id => encounter.participants.find(p => p.id === id && p.isActive)) const { patch, log } = nextTurn(encounter);
.filter(Boolean); await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, {
if (activePsInOrder.length === 0) { encounterPath,
updates: log.undo,
});
} catch (err) {
// nextTurn throws if no active participants — auto-end combat
if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return;
alert("No active participants left."); alert("No active participants left.");
await storage.updateDoc(encounterPath, { await storage.updateDoc(encounterPath, {
isStarted: false, isStarted: false,
@@ -1772,88 +1567,24 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
currentTurnParticipantId: null, currentTurnParticipantId: null,
round: encounter.round round: encounter.round
}); });
return;
}
let currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
let nextRound = encounter.round;
// Current participant was removed; find the next one after their old position in turnOrderIds
if (currentIndex === -1) {
const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId);
const candidateIds = [...(encounter.turnOrderIds || []).slice(rawPos + 1), ...(encounter.turnOrderIds || []).slice(0, rawPos)];
const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean);
currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1;
}
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
let newTurnOrderIds = encounter.turnOrderIds;
if (nextIndex === 0 && currentIndex !== -1) {
nextRound += 1;
// Rebuild turn order by initiative at the start of each new round so that participants
// activated mid-round (appended to the end) slot into proper initiative position next round.
const activePs = encounter.participants.filter(p => p.isActive);
const sorted = sortParticipantsByInitiative(activePs, encounter.participants);
newTurnOrderIds = sorted.map(p => p.id);
}
// When wrapping to a new round the next participant is first in the rebuilt order
const nextParticipant = (nextIndex === 0 && currentIndex !== -1)
? encounter.participants.find(p => p.id === newTurnOrderIds[0])
: activePsInOrder[nextIndex];
if (!nextParticipant) return;
try {
await storage.updateDoc(encounterPath, {
currentTurnParticipantId: nextParticipant.id,
round: nextRound,
turnOrderIds: newTurnOrderIds,
});
logAction(`${nextParticipant.name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }, {
encounterPath,
updates: {
currentTurnParticipantId: encounter.currentTurnParticipantId,
round: encounter.round,
turnOrderIds: [...encounter.turnOrderIds],
},
});
} catch (err) {
console.error("Error advancing turn:", err);
} }
}; };
const confirmEndEncounter = async () => { const confirmEndEncounter = async () => {
if (!db) return; if (!db) return;
try { try {
await storage.updateDoc(encounterPath, { const { patch, log } = endEncounter(encounter);
isStarted: false, await storage.updateDoc(encounterPath, patch);
isPaused: false,
currentTurnParticipantId: null,
round: 0,
turnOrderIds: []
});
await storage.updateDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null activeEncounterId: null
}); });
logAction(log.message, { encounterName: encounter.name }, {
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { updates: log.undo,
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
}); });
console.log("Encounter ended and deactivated from Player Display.");
} catch (err) { } catch (err) {
console.error("Error ending encounter:", err); alert("Failed to end encounter. Please try again.");
} }
setShowEndConfirm(false); setShowEndConfirm(false);
+25 -3
View File
@@ -38,11 +38,33 @@ export const MOCK_DB = {
return () => state.subscribers.get(path)?.delete(cb); return () => state.subscribers.get(path)?.delete(cb);
}, },
_notify(path) { _notify(path) {
// notify exact doc path subscribers // notify exact doc path subscribers (wrapped in act for test isolation).
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => cb()); // Set flag true around cb: testing-library asyncWrapper (waitFor) flips it
// false during poll, which makes raw react act() warn 'not configured'.
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
// notify parent collection subscribers // notify parent collection subscribers
const parent = path.split('/').slice(0, -1).join('/'); const parent = path.split('/').slice(0, -1).join('/');
if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => cb()); if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
}, },
nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); }, nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
_state: state, _state: state,
+21
View File
@@ -2,6 +2,27 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { resetMockDb } from './__mocks__/firebase/_mock-db'; import { resetMockDb } from './__mocks__/firebase/_mock-db';
// RTL v14: tell React this is an act environment. Without it every
// async update warns "current testing environment is not configured to
// support act(...)". RTL reads getGlobalThis(), so set on globalThis.
// Must be set before any component renders.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
// WARNING = FAILURE. Fail test on any console.error/warn (React act warnings,
// deprecations, prop errors). App code's own error logs allowed only inside
// try/catch failure paths — those tests should assert the error was handled,
// not silently log it. Override the spies to throw.
const originalError = console.error;
const originalWarn = console.warn;
console.error = (...args) => {
originalError(...args);
throw new Error(`console.error in test: ${args.map(String).join(' ').slice(0, 500)}`);
};
console.warn = (...args) => {
originalWarn(...args);
throw new Error(`console.warn in test: ${args.map(String).join(' ').slice(0, 500)}`);
};
// polyfill crypto.randomUUID for jsdom (used by generateId in App.js). // polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
if (!global.crypto) global.crypto = {}; if (!global.crypto) global.crypto = {};
if (!global.crypto.randomUUID) { if (!global.crypto.randomUUID) {
+3 -1
View File
@@ -123,7 +123,9 @@ describe('Combat -> Firebase', () => {
test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => { test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const switchBtn = screen.getByRole('switch'); // Two switches now (Hide player HP + Hide NPC/monster HP). Scope to player one.
const playerHpLabel = screen.getByText('Hide player HP');
const switchBtn = playerHpLabel.parentElement.querySelector('[role="switch"]');
fireEvent.click(switchBtn); fireEvent.click(switchBtn);
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('updateDoc');
+31 -24
View File
@@ -14,6 +14,7 @@ import App from '../App';
import { import {
renderApp, createCampaignViaUI, selectCampaignByName, renderApp, createCampaignViaUI, selectCampaignByName,
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
fastWaitFor,
} from './testHelpers'; } from './testHelpers';
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db'; import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
@@ -46,8 +47,10 @@ function getParticipantForm() {
function getParticipantRow(name) { function getParticipantRow(name) {
const lis = document.querySelectorAll('li'); const lis = document.querySelectorAll('li');
for (const li of lis) { for (const li of lis) {
const txt = li.textContent || ''; // participant rows have inline initiative input with unique aria-label.
if (txt.includes('Init:') && txt.includes(name)) { // Avoids matching CharacterManager roster cards (which also have HP text).
const initInput = li.querySelector(`input[aria-label="Initiative for ${name}"]`);
if (initInput) {
return within(li); return within(li);
} }
} }
@@ -60,7 +63,7 @@ async function addCharacterViaUI(name, maxHp, initMod) {
fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } }); fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } }); fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i })); fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
await waitFor(() => { await fastWaitFor(() => {
const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') &&
Array.isArray(c.data.players) && c.data.players.some(p => p.name === name)); Array.isArray(c.data.players) && c.data.players.some(p => p.name === name));
if (!call) throw new Error('char not persisted'); if (!call) throw new Error('char not persisted');
@@ -86,7 +89,7 @@ async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
if (!npcCheck.checked) fireEvent.click(npcCheck); if (!npcCheck.checked) fireEvent.click(npcCheck);
} }
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added'); if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added');
}); });
@@ -102,7 +105,7 @@ async function addCharacterParticipant(charName) {
if (!opt) throw new Error(`char option not found: ${charName}`); if (!opt) throw new Error(`char option not found: ${charName}`);
fireEvent.change(charSelect, { target: { value: opt.value } }); fireEvent.change(charSelect, { target: { value: opt.value } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added'); if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added');
}); });
@@ -110,13 +113,13 @@ async function addCharacterParticipant(charName) {
async function addAllCharacters() { async function addAllCharacters() {
fireEvent.click(screen.getByRole('button', { name: /Add All/i })); fireEvent.click(screen.getByRole('button', { name: /Add All/i }));
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('add all no-op'); if (!last) throw new Error('add all no-op');
}); });
} }
function applyDamage(name, amount) { async function applyDamage(name, amount) {
const row = getParticipantRow(name); const row = getParticipantRow(name);
const dmgBtn = row.queryByTitle('Damage'); const dmgBtn = row.queryByTitle('Damage');
if (!dmgBtn) { if (!dmgBtn) {
@@ -126,6 +129,11 @@ function applyDamage(name, amount) {
} }
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } }); fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
fireEvent.click(dmgBtn); fireEvent.click(dmgBtn);
await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
const p = last?.data?.participants?.find(x => x.name === name);
if (!p) throw new Error('damage no write');
});
} }
function applyHeal(name, amount) { function applyHeal(name, amount) {
const row = getParticipantRow(name); const row = getParticipantRow(name);
@@ -142,15 +150,17 @@ function toggleActive(name) {
} }
function openConditions(name) { function openConditions(name) {
const row = getParticipantRow(name); const row = getParticipantRow(name);
// idempotent: only click Conditions button if panel not already open for this row.
// Re-clicking toggles it closed.
const panel = row.queryByText('Toggle Conditions');
if (panel) return;
const btn = row.getByTitle('Conditions'); const btn = row.getByTitle('Conditions');
// idempotent: ensure panel open. Click toggles; if another participant's panel
// was open it's already closed by this participant's row focus, so just click.
fireEvent.click(btn); fireEvent.click(btn);
} }
function toggleCondition(name, label) { function toggleCondition(name, label) {
openConditions(name); openConditions(name);
// panel render is async (React state). Wait for button by title. // panel render is async (React state). Wait for button by title.
return waitFor(() => { return fastWaitFor(() => {
const condButtons = document.querySelectorAll('button[title]'); const condButtons = document.querySelectorAll('button[title]');
const target = [...condButtons].find(b => b.getAttribute('title') === label); const target = [...condButtons].find(b => b.getAttribute('title') === label);
if (!target) throw new Error(`condition button not found: ${label}`); if (!target) throw new Error(`condition button not found: ${label}`);
@@ -180,41 +190,41 @@ async function deathSave(name, saveNum) {
const row = getParticipantRow(name); const row = getParticipantRow(name);
const btn = row.getByTitle(`Death save ${saveNum}`); const btn = row.getByTitle(`Death save ${saveNum}`);
fireEvent.click(btn); fireEvent.click(btn);
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('deathSave no write'); if (!last) throw new Error('deathSave no write');
}); });
} }
async function nextTurn() { async function nextTurn() {
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('nextTurn no write'); if (!last) throw new Error('nextTurn no write');
}); });
} }
async function pauseCombat() { async function pauseCombat() {
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i })); fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last?.data?.isPaused) throw new Error('not paused'); if (!last?.data?.isPaused) throw new Error('not paused');
}); });
} }
async function resumeCombat() { async function resumeCombat() {
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i })); fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (last?.data?.isPaused) throw new Error('not resumed'); if (last?.data?.isPaused) throw new Error('not resumed');
}); });
} }
async function startCombat() { async function startCombat() {
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i })); fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
await waitFor(() => { await fastWaitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last?.data?.isStarted) throw new Error('not started'); if (!last?.data?.isStarted) throw new Error('not started');
}); });
} }
function toggleHidePlayerHp() { function toggleHidePlayerHp() {
fireEvent.click(screen.getByRole('switch')); fireEvent.click(screen.getByRole('switch', { name: /hide player hp/i }));
} }
function currentEncDoc() { function currentEncDoc() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
@@ -273,7 +283,7 @@ test('full 100-round combat scenario', async () => {
} }
// damage front monster every other round // damage front monster every other round
if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3)); if (r % 2 === 0) await recordAsync(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2)); if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2));
if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleCondition('Fighter', 'Stunned')); if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleCondition('Fighter', 'Stunned'));
if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2')); if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
@@ -292,16 +302,15 @@ test('full 100-round combat scenario', async () => {
// damage-to-0 + death save on Rogue around round 25 and 50 // damage-to-0 + death save on Rogue around round 25 and 50
if (r === 25) { if (r === 25) {
record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99)); await recordAsync(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1)); record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1));
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22)); record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
} }
if (r === 50) { if (r === 50) {
record(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99)); await recordAsync(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
record(`round ${r} deathSave Cleric x3`, async () => { await recordAsync(`round ${r} deathSave Cleric x2`, async () => {
await deathSave('Cleric', 1); await deathSave('Cleric', 1);
await deathSave('Cleric', 2); await deathSave('Cleric', 2);
await deathSave('Cleric', 3);
}); });
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24)); record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
} }
@@ -321,7 +330,7 @@ test('full 100-round combat scenario', async () => {
const modal = endConfirm.closest('.fixed.inset-0') || document.body; const modal = endConfirm.closest('.fixed.inset-0') || document.body;
const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim())); const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim()));
fireEvent.click(confirmBtn); fireEvent.click(confirmBtn);
await waitFor(() => { await fastWaitFor(() => {
const last = currentEncDoc(); const last = currentEncDoc();
if (last?.isStarted !== false) throw new Error('not ended'); if (last?.isStarted !== false) throw new Error('not ended');
}); });
@@ -334,7 +343,5 @@ test('full 100-round combat scenario', async () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`); console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
} }
// eslint-disable-next-line no-console
console.log(`\n=== SCENARIO: ${RESULTS.length - failed.length}/${RESULTS.length} phases ok ===\n`);
expect(failed).toEqual([]); expect(failed).toEqual([]);
}, 240000); // long timeout: 100 rounds }, 240000); // long timeout: 100 rounds
+3
View File
@@ -4,6 +4,9 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
import App from '../App'; import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db'; import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
// fast waitFor: 5ms poll vs default 50ms. Cuts scenario ~8x.
export const fastWaitFor = (cb, opts) => waitFor(cb, { interval: 5, timeout: 1000, ...opts });
// Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager). // Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager).
export function getParticipantForm() { export function getParticipantForm() {
const heading = screen.getByText('Add Participants'); const heading = screen.getByText('Add Participants');