Compare commits
114 Commits
v0.2.1
...
rework-backend
| Author | SHA1 | Date | |
|---|---|---|---|
| c0998da0a7 | |||
| d00cc104c9 | |||
| 36d7186a54 | |||
| 08c27c1ca5 | |||
| 0514939c51 | |||
| a2c63cc77f | |||
| e22f412c52 | |||
| 4406fd2045 | |||
| 81c0b26b71 | |||
| da25f46e3e | |||
| c1d982b4a4 | |||
| afdd72e829 | |||
| 58ae04b400 | |||
| d73405753a | |||
| 3b07fc27b0 | |||
| af165f4491 | |||
| dbd0c75792 | |||
| 750ee99080 | |||
| 313a897e4b | |||
| 3ea67019d2 | |||
| 7c3ec105d5 | |||
| d1cbe7091a | |||
| 5d3a0607ef | |||
| 94b62dc5ab | |||
| fcddb58b8b | |||
| 7467a8d30f | |||
| 5521a2f6c6 | |||
| 494327ff17 | |||
| c72b88f8bb | |||
| 0473eacc1d | |||
| c6d3b7e1a6 | |||
| e0f75cfb6c | |||
| b62996dcbf | |||
| 260fb314bc | |||
| fb7fbb2263 | |||
| 435e109070 | |||
| b024fa08bb | |||
| 49ea39ea93 | |||
| b2fd06ed17 | |||
| e514a48d6e | |||
| c90fc6ffb0 | |||
| d979b03f2e | |||
| be481767f0 | |||
| bac94d85ff | |||
| 08c6146cf7 | |||
| d48ecf1460 | |||
| c314d1975e | |||
| a8e88cf0f0 | |||
| d35a730e12 | |||
| 912c493974 | |||
| 40fc4e596b | |||
| 80b454d087 | |||
| 2756b7b3eb | |||
| f81308a0df | |||
| 33e0e52789 | |||
| 13490fe3de | |||
| 7866dec83b | |||
| 891fc696d9 | |||
| 9fd0f3ec38 | |||
| 6630fd9158 | |||
| 1d4c561c09 | |||
| 84a8b78acd | |||
| 52866784b2 | |||
| 35cd1581e3 | |||
| ed67535b1f | |||
| 17245dfa1b | |||
| e843acdf8a | |||
| 74b4c2c42d | |||
| d1ee69a70a | |||
| a5a4df78f0 | |||
| b095e37bfe | |||
| 54e8df9ffa | |||
| e743d40e8d | |||
| 3e84f28325 | |||
| 812298fa73 | |||
| 5bb9e5fc19 | |||
| 35b5a1d238 | |||
| d581e60ba3 | |||
| 4158a1634d | |||
| 0c1196aee1 | |||
| 672f042b60 | |||
| b6555648ee | |||
| 84dd17e174 | |||
| 12b24eb707 | |||
| 2ee2bba93b | |||
| 9457f48b23 | |||
| fa19913e23 | |||
| 0e76fb2fc7 | |||
| e06adaa081 | |||
| d679c9d1e9 | |||
| ad7979d8fd | |||
| 33b27775b4 | |||
| d96d3a6cf2 | |||
| 439f48871e | |||
| 35990f588e | |||
| b11fbe4715 | |||
| 7b52480329 | |||
| 58cc588726 | |||
| e200f59b7e | |||
| bb65709e26 | |||
| 33d831af54 | |||
| 4150267925 | |||
| e23cea205a | |||
| 6cd25dadaa | |||
| 90cfb36b56 | |||
| 7676751a5b | |||
| 16118dd958 | |||
| 33c93ab86b | |||
| 451151628c | |||
| 1e0df31cd4 | |||
| 3be9b0a921 | |||
| 895fa06227 | |||
| 82f45e60f0 | |||
| ceba7632f8 |
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# pre-push: run test suites before push. Skip with --no-verify.
|
||||
echo "[pre-push] running tests..."
|
||||
npm run test:all || { echo "[pre-push] tests failed. push aborted. (skip: git push --no-verify)"; exit 1; }
|
||||
exit 0
|
||||
@@ -1,4 +1,3 @@
|
||||
# .gitignore
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
@@ -7,3 +6,10 @@ dist
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.log
|
||||
data/*.sqlite
|
||||
data/*.sqlite-*
|
||||
server/data/*.sqlite
|
||||
server/data/*.sqlite-*
|
||||
/data
|
||||
/scratch
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Dev server at http://localhost:3000
|
||||
npm run build # Production build
|
||||
npm test # Run tests (no test files currently exist)
|
||||
```
|
||||
|
||||
Docker:
|
||||
```bash
|
||||
docker build -t ttrpg-initiative-tracker .
|
||||
docker run -p 8080:80 --rm --name ttrpg-tracker-app ttrpg-initiative-tracker
|
||||
```
|
||||
|
||||
The Dockerfile uses `NODE_OPTIONS=--openssl-legacy-provider npm run build` to work around an OpenSSL compatibility issue between Node 18 and react-scripts 5.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single-page React app (Create React App) for DMs to manage TTRPG combat encounters with a real-time player display. The entire application — all components, hooks, Firebase init, and routing — lives in **`src/App.js`** (~2500 lines).
|
||||
|
||||
**Key dependencies:** React 18, Firebase SDK v10 (Firestore + anonymous auth), Tailwind CSS v3, lucide-react icons.
|
||||
|
||||
### Two App Modes (query-param routing)
|
||||
|
||||
- Default URL → `AdminView`: full DM interface (campaign/encounter/participant management)
|
||||
- `?playerView=true` or `/display` → `DisplayView`: read-only player-facing view for a second monitor
|
||||
|
||||
### Firebase / Firestore
|
||||
|
||||
All app state lives in Firestore under `artifacts/{APP_ID}/public/data/`:
|
||||
- `campaigns/` — campaign documents
|
||||
- `campaigns/{id}/encounters/` — sub-collections with a `participants` array per encounter
|
||||
- `activeDisplay/status` — single doc controlling what the player display shows
|
||||
|
||||
`APP_ID` defaults to `"ttrpg-initiative-tracker-default"` and can be overridden via `REACT_APP_TRACKER_APP_ID`.
|
||||
|
||||
All users authenticate anonymously (Firebase Anonymous Auth). If `window.__initial_auth_token` is set, a custom token is used instead.
|
||||
|
||||
Real-time updates use two custom hooks in App.js: `useFirestoreDocument(docPath)` and `useFirestoreCollection(collectionPath)`, both backed by `onSnapshot`.
|
||||
|
||||
### App.js Sections (in order)
|
||||
|
||||
1. Constants — `APP_VERSION`, `CONDITIONS` array, defaults
|
||||
2. Firebase config — reads `REACT_APP_FIREBASE_*` env vars
|
||||
3. Firestore path helpers — `getPath` object
|
||||
4. Utility functions — `generateId()`, `rollD20()`, sort helpers
|
||||
5. Custom hooks — `useFirestoreDocument`, `useFirestoreCollection`
|
||||
6. Reusable UI — `Modal`, `ConfirmationModal`, `LoadingSpinner`, `ErrorDisplay`
|
||||
7. Feature components — forms, managers, controls (see below)
|
||||
8. `AdminView` (~line 1942) — root DM component
|
||||
9. `DisplayView` (~line 2186) — root player component
|
||||
10. `App` (~line 2426) — auth + routing
|
||||
|
||||
Major feature components: `CreateCampaignForm`, `CreateEncounterForm`, `EditParticipantModal`, `CharacterManager`, `ParticipantManager`, `InitiativeControls`, `EncounterManager`.
|
||||
|
||||
### Styling
|
||||
|
||||
Tailwind CSS with two custom font families configured in `tailwind.config.js`:
|
||||
- `font-cinzel` — Cinzel (serif, used for headings)
|
||||
- `font-garamond` — Alegreya Sans (default body font)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Copy `env.example` to `.env.local` and fill in Firebase credentials:
|
||||
|
||||
```
|
||||
REACT_APP_FIREBASE_API_KEY
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN
|
||||
REACT_APP_FIREBASE_PROJECT_ID
|
||||
REACT_APP_FIREBASE_STORAGE_BUCKET
|
||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID
|
||||
REACT_APP_FIREBASE_APP_ID
|
||||
REACT_APP_TRACKER_APP_ID # optional, Firestore namespace
|
||||
```
|
||||
@@ -38,6 +38,9 @@ LABEL stage="nginx-server"
|
||||
# Copy the build output from the 'build' stage to Nginx's html directory
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Replace default nginx config with one that handles SPA client-side routing
|
||||
COPY nginx-docker.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80 (Nginx default)
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
||||
("Public License"). To the extent this Public License may be
|
||||
interpreted as a contract, You are granted the Licensed Rights in
|
||||
consideration of Your acceptance of these terms and conditions, and the
|
||||
Licensor grants You such rights in consideration of benefits the
|
||||
Licensor receives from making the Licensed Material available under
|
||||
these terms and conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-NC-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution, NonCommercial, and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. NonCommercial means not primarily intended for or directed towards
|
||||
commercial advantage or monetary compensation. For purposes of
|
||||
this Public License, the exchange of the Licensed Material for
|
||||
other material subject to Copyright and Similar Rights by digital
|
||||
file-sharing or similar means is NonCommercial provided there is
|
||||
no payment of monetary compensation in connection with the
|
||||
exchange.
|
||||
|
||||
l. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
m. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
n. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part, for NonCommercial purposes only; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material for
|
||||
NonCommercial purposes only.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
c. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties, including when
|
||||
the Licensed Material is used other than for NonCommercial
|
||||
purposes.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-NC-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database for NonCommercial purposes
|
||||
only;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material,
|
||||
including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
@@ -1,15 +1,21 @@
|
||||
# TTRPG Initiative Tracker (v0.2.1)
|
||||
# TTRPG Initiative Tracker (v0.2.5)
|
||||
|
||||
This application is the result of not having the exact tool I want to use, and a few sessions of [vibe-coding](https://www.youtube.com/watch?v=Tw18-4U7mts) with [Google Gemini](https://developers.google.com/gemini-code-assist/docs/overview).
|
||||

|
||||
|
||||
This application is the result of not having the exact tool I want to use, and after a few sessions of [vibe-coding](https://www.youtube.com/watch?v=Tw18-4U7mts) with [Google Gemini](https://developers.google.com/gemini-code-assist/docs/overview), here it is!.
|
||||
|
||||
**Use at your own risk.**
|
||||
|
||||
A web-based application designed to help Dungeon Masters (DMs) manage and display combat initiative for tabletop role-playing games (TTRPGs). It features a DM admin interface for controlling encounters and a separate player display view suitable for an external monitor.
|
||||
|
||||

|
||||
|
||||
Have you tried it? Got feedback or questions? Discuss here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16)
|
||||
|
||||
## Features
|
||||
|
||||

|
||||
|
||||
* **Campaign Management:**
|
||||
* Create campaigns to organize game sessions.
|
||||
* Option to set a custom background image URL for the player display on a per-campaign basis.
|
||||
@@ -17,6 +23,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
|
||||
* **Character Management:**
|
||||
* Add and manage characters (player characters) within each campaign.
|
||||
* Set default Max HP and default Initiative Modifier for each campaign character.
|
||||
* The character list is collapsible to save screen space.
|
||||
* **Encounter Management:**
|
||||
* Create multiple encounters per campaign.
|
||||
* Add characters from the campaign roster (with auto-rolled initiative based on their modifier and HP pre-filled from campaign defaults) or add custom monsters.
|
||||
@@ -24,16 +31,38 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
|
||||
* "Add All Campaign Characters" button for quickly populating encounters with initiative rolls.
|
||||
* DM controls to start, pause/resume, advance turns, and end encounters.
|
||||
* Visual feedback for rolled initiative when adding individual participants.
|
||||
* Participants can be added mid-combat while the encounter is paused; the turn order recalculates on resume.
|
||||
* **HP Tracking:**
|
||||
* Apply damage or healing to any participant during combat directly from the DM view.
|
||||
* When a participant reaches 0 HP they are automatically marked inactive and shown with a skull (☠️) icon.
|
||||
* Healing a dead participant above 0 HP revives them and reactivates them in the turn order.
|
||||
* **Death Save Tracking:**
|
||||
* When a player character reaches 0 HP, three death save checkboxes appear in the DM view.
|
||||
* Marking the third death save triggers a dissolve animation on the player display, then removes the participant from the encounter.
|
||||
* **Conditions:**
|
||||
* Apply any of 22 conditions to a participant, including all standard D&D conditions plus several extras: Alchemist Fire 🔥, Bardic Inspiration 🎵, Blinded 🙈, Charmed 💘, Deafened 🔇, Exhaustion 😴, Frightened 😱, Grappled 🤜, Grazed 🩹, Incapacitated 💫, Invisible 👻, Paralyzed ⚡, Petrified 🗿, Poisoned 🤢, Prone ⬇️, Restrained 🕸️, Sapped 🔨, Shield 🛡️, Slowed 🐌, Stunned 💥, Unconscious 💤, Vexed 🎯.
|
||||
* Active conditions are shown as emoji badges on both the DM view and the player display.
|
||||
* Conditions can be toggled directly from the badge or from an expandable condition picker per participant.
|
||||
* **Player Display:**
|
||||
* A clean interface showing the current initiative order, participant HP (monster HP totals are hidden, only the bar is shown), and current turn.
|
||||
* NPCs (monster-type) are visually distinct from hostile monsters.
|
||||
* Displays custom campaign background if set.
|
||||
* Shows a "Game Session Paused" message when no encounter is active or if the current encounter is paused by the DM.
|
||||
* Player display is opened in a separate window via a button in the DM's header.
|
||||
* A clean interface showing the current initiative order, round number, participant HP, and current turn.
|
||||
* HP bars use color-coded thresholds: green (above half), yellow (quarter to half), red (below quarter), dark red (dead).
|
||||
* Monster and NPC HP totals are hidden; only the color-coded bar is shown.
|
||||
* Player character HP can be hidden from the player display via a toggle in the DM's initiative controls ("Hide player HP"). When enabled, only the color-coded bar is shown for characters as well.
|
||||
* Active conditions are displayed as emoji badges on each participant's card.
|
||||
* Inactive participants (and dead ones) are greyed out with a grayscale filter.
|
||||
* Inactive monsters (e.g. pre-staged or summoned reserves) are hidden from the player display entirely; inactive player characters still appear greyed out.
|
||||
* The current participant's card auto-scrolls into view when the turn advances.
|
||||
* NPCs are visually distinct from hostile monsters, which are distinct from player characters.
|
||||
* Displays a custom campaign background image if one is set.
|
||||
* Shows a "Game Session Paused" message when no encounter is active, or "(Combat Paused)" when the DM pauses a running encounter.
|
||||
* Player display is opened in a separate window via the "Open Player Window" button in the DM's header.
|
||||
* A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor.
|
||||
* A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active.
|
||||
* **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page.
|
||||
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display.
|
||||
* **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order.
|
||||
* **Responsive Design:** Styled with Tailwind CSS.
|
||||
* **Confirmation Modals:** Implemented for destructive actions like deleting campaigns, characters, encounters, or ending combat.
|
||||
* **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -44,8 +73,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
|
||||
|
||||
## App Usage Overview
|
||||
|
||||

|
||||
*(Replace with an actual screenshot of your DM view)*
|
||||

|
||||
|
||||
The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat encounters and display the initiative order to players. Here's a typical workflow:
|
||||
|
||||
@@ -65,16 +93,33 @@ The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat en
|
||||
* Once participants are added and initiative is set, click "Start Encounter". This also automatically makes the encounter live on the Player Display.
|
||||
* Use the "Pause" button to temporarily halt combat. While paused, you can adjust HP and re-order tied initiatives. Click "Resume" to continue.
|
||||
* Use the "Next Turn" button (disabled when paused) to advance through the initiative order. The current combatant will be highlighted.
|
||||
* Apply damage or healing to participants directly in the Admin View.
|
||||
* Mark participants as inactive (e.g., if knocked out) using the toggle next to their name.
|
||||
* Apply damage or healing to participants directly in the Admin View. Participants at 0 HP are automatically deactivated and marked with a skull icon. Healing them above 0 HP revives them.
|
||||
* For player characters at 0 HP, track death saving throws using the three checkboxes that appear. Marking the third failure triggers a death animation on the player display and removes the participant.
|
||||
* Apply or remove conditions (Blinded, Charmed, Poisoned, etc.) using the ✨ button next to each participant. Active conditions appear as emoji badges on both the DM view and the player display.
|
||||
* Mark participants as inactive (e.g., if knocked out) using the toggle next to their name. Inactive monsters are hidden from the player display entirely, making this useful for pre-staging summoned reserves.
|
||||
* Toggle **Hide player HP** in the initiative controls to show or hide player character HP values on the player display.
|
||||
* Click "End Encounter" (with confirmation) when combat is over. This also deactivates it from the Player Display.
|
||||
|
||||
2. **Player Display Window:**
|
||||
* This window (opened by the DM via `/?playerView=true` URL) shows a simplified, header-less view of the active encounter.
|
||||
* It displays the initiative order, current turn, round number, and participant HP (monster/NPC HP values are hidden, only the bar is shown).
|
||||
* NPCs are styled with a muted gray background, distinct from hostile monsters (custom reddish-brown) and player characters (blueish).
|
||||
* If a custom background URL was set for the campaign, it will be displayed.
|
||||
* If no encounter is active on the Player Display, or if the current encounter is paused by the DM, it will show an appropriate message ("Game Session Paused" or "Combat Paused").
|
||||
* This window (opened by the DM via the "Open Player Window" button, or accessed directly at `/display` or `/?playerView=true`) shows a simplified, header-less view of the active encounter.
|
||||
* It displays the initiative order, current turn, round number, and participant HP bars. Monster/NPC HP totals are hidden; only the color-coded bar is shown. Player character HP is shown numerically unless the DM has enabled "Hide player HP".
|
||||
* HP bars are color-coded: green (above 50%), yellow (25–50%), red (below 25%), dark red (dead/0 HP).
|
||||
* Active conditions are shown as emoji badges on each participant's card.
|
||||
* The current participant's card automatically scrolls into view when the turn advances.
|
||||
* NPCs are styled with a muted gray background, distinct from hostile monsters (reddish-brown) and player characters (dark blue/indigo).
|
||||
* Dead and inactive participants are greyed out with a grayscale filter. Inactive monsters are hidden entirely.
|
||||
* When a player character's third death save is marked, a dissolve animation plays on the display before the participant is removed.
|
||||
* If a custom background URL was set for the campaign, it will be displayed with a semi-transparent overlay behind the participant cards.
|
||||
* If no encounter is active on the Player Display, it shows "Game Session Paused". If the current encounter is paused by the DM, it shows "(Combat Paused)".
|
||||
* Use the **fullscreen button** (top-right) to go fullscreen — great for a dedicated second monitor. Press Escape or click again to exit.
|
||||
* Use the **prevent sleep toggle** (moon icon, top-right) to keep the screen awake using the browser Wake Lock API. The icon turns amber and switches to a coffee cup when active.
|
||||
|
||||
3. **Combat Log (`/logs`):**
|
||||
* A dedicated page that records all significant combat events: encounter starts and ends, turn advances, HP changes, condition changes, participant additions/removals, and activation toggles.
|
||||
* Each entry is timestamped and tagged with the encounter name for easy reference.
|
||||
* Most entries have an **↩ Undo** button. Clicking it rolls back that specific action in Firestore — restoring HP, conditions, turn order, or participant state to what it was before. The entry is then greyed out and marked "rolled back".
|
||||
* The log can be cleared in bulk from the page (with confirmation).
|
||||
* Accessible via the "Logs" link in the DM header or directly at `/logs`.
|
||||
|
||||
This flow allows the DM to prepare and run encounters efficiently while providing a clear, real-time view for the players.
|
||||
|
||||
@@ -92,7 +137,7 @@ This flow allows the DM to prepare and run encounters efficiently while providin
|
||||
|
||||
1. **Clone the Repository:**
|
||||
```bash
|
||||
git clone <your-repository-url>
|
||||
git clone https://code.draft13.com/robert/ttrpg-initiative-tracker
|
||||
cd ttrpg-initiative-tracker
|
||||
```
|
||||
|
||||
@@ -183,7 +228,6 @@ This project includes a `Dockerfile` to containerize the application for deploym
|
||||
## Project Structure
|
||||
|
||||
<pre>
|
||||
|
||||
ttrpg-initiative-tracker/
|
||||
├── .dockerignore # Specifies intentionally untracked files that Docker should ignore
|
||||
├── .env.example # Example environment variables
|
||||
@@ -202,7 +246,6 @@ ttrpg-initiative-tracker/
|
||||
├── App.js # Main application component
|
||||
├── index.css # Global styles (including Tailwind directives)
|
||||
└── index.js # React entry point
|
||||
|
||||
</pre>
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# TODO
|
||||
|
||||
Backlog of bugs + long-term items, from user. Milestones live in
|
||||
REWORK_PLAN.md.
|
||||
|
||||
## Feature backlog
|
||||
|
||||
### CRITICAL BUG - storage
|
||||
- docker for sql is not using persistant storage...
|
||||
|
||||
### feat - campaign section rollup
|
||||
|
||||
### feat - add all characters to participants list
|
||||
|
||||
### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
|
||||
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
|
||||
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
|
||||
no stale clobber.
|
||||
- Replaces fragile `/logs` snapshot-write undo.
|
||||
- Migration: keep old undo working for existing entries until cleared; new
|
||||
format for new entries.
|
||||
- Related: BUG-7 (reorder no undo).
|
||||
|
||||
## Architecture: 1-list turn order model (DONE)
|
||||
- Single source: turnOrderIds === participants.map(id). No re-sort after
|
||||
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
|
||||
- Drag (reorder) overrides initiative --- cross-init allowed, DM choice.
|
||||
- startEncounter sorts ALL participants by init once, then frozen.
|
||||
- addParticipant splices by init pos. remove/toggle/reorder sync list.
|
||||
- Display renders participants[] directly (no sortParticipantsByInitiative).
|
||||
- BUG-6 (reorder divergence) fixed structurally. BUG-5 (rotation) held
|
||||
(500 rounds CLEAN).
|
||||
|
||||
### FEAT-3: initiative first-class entry (add + edit)
|
||||
- Current: only initMod at char-build. No initiative field at add-participant
|
||||
or edit. 3-step to set after other steps.
|
||||
- Need: initiative field at add-char, add-monster, AND edit participant.
|
||||
- Separate design + RED. Own work item.
|
||||
- Related: tie-break = drag order (current, works). Expose clearly.
|
||||
|
||||
### FEAT-1: Dead participants stay in turn order --- DONE
|
||||
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds`
|
||||
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get
|
||||
death-save turn. `isActive` = DM toggle only.
|
||||
- Tests: `shared/tests/turn.dead-skip.test.js` (4 green). Char tests updated
|
||||
to new behavior.
|
||||
|
||||
### FEAT-2: upgrade app internal logs to be parseable
|
||||
- Goal: combat logs in Firestore store enough structured state to run
|
||||
skip/rotation analysis on ANY historic round --- not just replay stdout.
|
||||
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must
|
||||
guess roster from message strings. Brittle.
|
||||
- Upgrade: add structured fields at turn-state mutation log sites in
|
||||
App.js (startEncounter, toggleActive, addParticipant, removeParticipant,
|
||||
applyHpChange death/revive, togglePause, nextTurn):
|
||||
```
|
||||
turnSnapshot: { round, currentTurnParticipantId, turnOrderIds, activeIds }
|
||||
```
|
||||
- Then `scripts/analyze-turns.js` ingests app logs directly (adapter fetch).
|
||||
Works on real game sessions, any round, deterministic.
|
||||
- Parser scaffold NOW ingests replay stdout only (stopgap until FEAT-2).
|
||||
|
||||
## Confirmed bugs (tests written, NOT fixed)
|
||||
|
||||
### BUG-1: addParticipant + pause/resume corrupts turn rotation
|
||||
- **RESOLVED** as side effect of BUG-2 fix (dup-id rejection broke chain).
|
||||
- Audit: 0 violations / 100 rounds after BUG-2 fix.
|
||||
- Replay: 10 rounds clean, no skip/dupe.
|
||||
- Audit: 128 violations / 100 rounds, 4 symptom faces.
|
||||
- Symptom chain (one bug family):
|
||||
1. pause blocks nextTurn advance → totalTurns stays frozen (e.g. 120)
|
||||
2. addParticipant re-adds same `r${totalTurns}` id (BUG-2: no dedup)
|
||||
3. togglePause resume rebuilds turnOrderIds → dup id appears x2
|
||||
4. nextTurn gets stuck on dup id → rotation breaks
|
||||
5. eventually nextTurn throws 'Encounter not running'
|
||||
- Symptom counts (audit-state.js, 100 rounds):
|
||||
62x turnOrder-no-dup, 52x rotation-dupes, 14x nextTurn-throws
|
||||
- Repro in replay round 10+: current stuck on one participant forever,
|
||||
nextTurn returns same id, round never advances.
|
||||
- Clean minimal repro (shared/tests/turn.pause-add.test.js) PASSES = combo
|
||||
needs more state than single add+pause. Audit authoritative repro.
|
||||
- Clean subsystems (zero violations): HP bounds, isActive, deathSave
|
||||
range, conditions, removeParticipant orphans.
|
||||
- Real repro = `node scripts/audit-state.js` (or audit-rotation.js).
|
||||
|
||||
### BUG-2: addParticipant allows duplicate id
|
||||
- **FIXED** (commit: addParticipant throws on dup id).
|
||||
- Test: `shared/tests/turn.characterization.test.js` 'addParticipant rejects
|
||||
duplicate id' --- GREEN.
|
||||
|
||||
### bug-3 was a halucination has been removed
|
||||
|
||||
### BUG-4: hide-player-HP breaks display view (preexisting) --- PROD FIXED, TEST RED (mock bug)
|
||||
- **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls
|
||||
use `{merge:true}` which is IGNORED (setDoc = replace per contract).
|
||||
Each write clobbers other fields on activeDisplay/status doc.
|
||||
- line 1619: hide-HP toggle → clobbers campaignId+encounterId (display paused)
|
||||
- line 1648: start combat → clobbers hidePlayerHp
|
||||
- line 1779: end combat → clobbers hidePlayerHp
|
||||
- line 1997: deactivate → clobbers hidePlayerHp
|
||||
- line 2002: activate → clobbers hidePlayerHp
|
||||
- Toggle "hide player HP" in admin → display view flips to "Game Session Paused".
|
||||
- Toggling back does NOT recover. Must re-activate encounter in encounters
|
||||
panel to restore display.
|
||||
- Expected: hide-HP toggle updates one field on activeDisplay/status doc,
|
||||
display stays live on current encounter.
|
||||
- Likely cause: toggle writes to wrong path, or clobbers activeCampaignId/
|
||||
activeEncounterId with null (setDoc replace vs updateDoc patch).
|
||||
- Fix: use updateDoc (patch) not setDoc (replace); or include all existing
|
||||
fields when writing.
|
||||
- Status update (2026-07): all 5 sites now use `{merge:true}`. Real firebase
|
||||
adapter honors merge → production works. BUT jsdom test still RED because
|
||||
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
|
||||
(no actual merge). Mock must simulate firebase merge semantics for test
|
||||
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
|
||||
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, ws adapter
|
||||
uses PATCH). Decide which.
|
||||
- Test: render App + DisplayView, toggle hide-HP, assert display still shows
|
||||
encounter (not paused).
|
||||
|
||||
### BUG-5: mid-round addParticipant/revive corrupts rotation --- FIXED
|
||||
- Fixed (commit `494327f`). Slot-array turn order + DRY advance core
|
||||
`nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate.
|
||||
- 500-round replay: 0 skips, 0 double-acts.
|
||||
|
||||
### BUG-6: reorderParticipants doesn't update turnOrderIds --- FIXED
|
||||
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds =
|
||||
participants.map(id) always. reorder cross-init allowed (DM override).
|
||||
Display === rotation by construction.
|
||||
- Test: `shared/tests/turn.reorder.test.js` 'reorder updates turnOrderIds' (RED).
|
||||
- `reorderParticipants(enc, draggedId, targetId)` swaps two same-initiative
|
||||
participants in `participants[]` array but leaves `turnOrderIds` unchanged.
|
||||
- nextTurn rotates via `turnOrderIds` only → reorder has NO effect on combat
|
||||
rotation. Mid-encounter drag-drop = pointless.
|
||||
- replay-combat.js calls reorderParticipants with WRONG signature
|
||||
`(enc, reorderedArray)` --- swallowed by try/catch, silent no-op. So
|
||||
replay never exercised real path either.
|
||||
- Fix: reorder must also update turnOrderIds to match new participant order
|
||||
(within same-initiative tie).
|
||||
|
||||
### BUG-7: reorderParticipants has no undo
|
||||
- Test: `shared/tests/turn.undo.test.js` 'reorderParticipants has no undo' (GREEN doc).
|
||||
- `reorderParticipants` returns `log: null`. Other ops return `log.undo`.
|
||||
- Cannot undo drag-drop. Candidate for undo system (M6).
|
||||
|
||||
### BUG-8: ws adapter has no reconnect
|
||||
- Test: `server/tests/ws-reconnect.test.js` (RED).
|
||||
- WS dies (idle/error/close) → `wsReady=null`, subscribers dead forever.
|
||||
- Display frozen until full reload.
|
||||
- Fix: `onclose` → reconnect + re-subscribe existing paths.
|
||||
|
||||
### BUG-10: deact+reactivate same round double-acts participant
|
||||
- Discovered in 500-round replay (3 occurrences). DISTINCT from BUG-5.
|
||||
- Pattern: participant acts → DM deactivates them → DM reactivates them
|
||||
same round → `computeTurnOrderAfterAddition` re-inserts by initiative
|
||||
(front) → acts AGAIN before round ends.
|
||||
- No "acted-this-round" guard. Slot-array model has no per-round-acted set.
|
||||
- Edge case (DM deact+reactivate same participant same round).
|
||||
- Fix candidate: track actedThisRound set, skip re-acted; OR insertion
|
||||
places reactivate AFTER current position (not by initiative).
|
||||
- Parser now discounts deact-current advances, so this surfaced real.
|
||||
|
||||
### BUG-11: FE Combat.scenario test crashes (pre-existing)
|
||||
- `src/tests/Combat.scenario.test.js:254` deathSave query helper throws
|
||||
(button not found).
|
||||
- Baseline (my changes removed) also exit=1. Pre-existing, not regression.
|
||||
- Crashes whole FE test run (process dies).
|
||||
|
||||
### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics
|
||||
- Discovered 7/1 replay. `reorderParticipants` (shared/turn.js:522) = pure
|
||||
drag, no pointer logic. Swapping two actors across current pointer mid-round
|
||||
= ambiguous who-acted-this-round. Earlier replay arbitrary swaps showed
|
||||
skip/double (R9 Summon3 2x, R11 Goblin1 2x) before fix restricted swaps to
|
||||
upcoming-only.
|
||||
- Replay now avoids crossing (adjacent upcoming pair only, commit af165f4).
|
||||
Real app untested: if DM drags actor past current pointer mid-round, skip/
|
||||
double behavior undefined.
|
||||
- Decide: block cross-pointer reorder, or define acted-semantics. RED needed.
|
||||
|
||||
### BUG-14: addParticipant init-insertion breaks after drag-reorder
|
||||
- Discovered 7/1 replay. `computeTurnOrderAfterAddition` scans for first id
|
||||
with init < addedInit, assumes list init-sorted. After drag, list NOT sorted
|
||||
→ scan hits wrong slot.
|
||||
- Trace turn 30→31: list `[Goblin1:20,Goblin2:22,...]` (drag moved Goblin1
|
||||
before Goblin2). Add Reinforce3 init 21 → scan hits Goblin1:20 (idx 0, <21)
|
||||
first → insert at 0. Should slot after Goblin2:22. WRONG.
|
||||
- Root conflict: 1-list model = drag source of truth (no re-sort); addParticipant
|
||||
= init-based insertion (needs sorted list). After ANY drag, add-insertion
|
||||
meaningless.
|
||||
- Proposed fix: append to end always (option A). DM drags to position. Matches
|
||||
drag = source of truth. Makes `computeTurnOrderAfterAddition` trivial.
|
||||
- Related: FEAT-3 (initiative first-class field).
|
||||
|
||||
## Pipeline (bugs only --- milestones live in REWORK_PLAN.md)
|
||||
- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites
|
||||
- [x] BUG-5: fixed (1-list model, 500 rounds clean)
|
||||
- [x] BUG-6: fixed structurally (1-list model)
|
||||
- [x] BUG-12: fixed --- campaign selection follows activeDisplay
|
||||
- [x] BUG-15: fixed --- DisplayView no longer re-sorts (drag order preserved)
|
||||
- [x] BUG-8: ws adapter reconnect (implemented + GREEN)
|
||||
- [ ] BUG-10: deact+reactivate double-act
|
||||
- [ ] BUG-11: FE Combat.scenario crash
|
||||
- [ ] BUG-13: reorder cross-pointer semantics (RED + decide block/allow)
|
||||
- [ ] BUG-14: addParticipant init-insert breaks post-drag (append? + RED)
|
||||
@@ -4,28 +4,36 @@
|
||||
|
||||
# Ignore Node.js modules (they will be installed in the Docker image)
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Ignore build output (it will be generated in the Docker image)
|
||||
build
|
||||
dist
|
||||
|
||||
# Ignore Docker files themselves
|
||||
# Ignore Docker files themselves (Caddyfile MUST stay in context for frontend build)
|
||||
Dockerfile
|
||||
Dockerfile.ws
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore any local environment files if you have them
|
||||
.env
|
||||
# .env.local
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Ignore IDE and OS-specific files
|
||||
.vscode/
|
||||
.idea/
|
||||
.idea
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Ignore local sqlite data + scratch diagnostics (never bake into image)
|
||||
data/
|
||||
scratch/
|
||||
tmp/
|
||||
@@ -0,0 +1,18 @@
|
||||
# Caddyfile — single-container (caddy + node)
|
||||
# Caddy serves built frontend, proxies /api + /ws to node backend on :4001.
|
||||
# Node never exposed directly; only caddy on :80.
|
||||
|
||||
:80 {
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:4001
|
||||
}
|
||||
handle /ws {
|
||||
reverse_proxy 127.0.0.1:4001
|
||||
}
|
||||
# catch-all: static frontend (SPA fallback)
|
||||
handle {
|
||||
root * /srv
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# docker/Dockerfile — single container: caddy (front) + node (back).
|
||||
# Build context = repo root.
|
||||
# ---- build stage: frontend + install backend deps ----
|
||||
FROM node:18-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm install --include-workspace-root
|
||||
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
COPY tailwind.config.js postcss.config.js ./
|
||||
|
||||
# better-sqlite3 native build (alpine musl)
|
||||
RUN cd server && npm rebuild better-sqlite3
|
||||
|
||||
# build frontend (ws storage, same-origin via caddy)
|
||||
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
|
||||
ENV REACT_APP_STORAGE=ws
|
||||
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
|
||||
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
||||
|
||||
# prune backend dev deps for runtime
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# ---- runtime stage: caddy + node ----
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache caddy
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/shared/node_modules ./shared/node_modules
|
||||
COPY --from=build /app/server/node_modules ./server/node_modules
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/shared/package.json ./shared/
|
||||
COPY --from=build /app/server/package.json ./server/
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
# built frontend served by caddy
|
||||
COPY --from=build /app/build /srv
|
||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4001
|
||||
ENV DB_PATH=/data/tracker.sqlite
|
||||
|
||||
EXPOSE 80
|
||||
WORKDIR /app
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# docker/docker-compose.yml — single container: caddy (front) + node (back).
|
||||
# Usage (from repo root):
|
||||
# docker compose -f docker/docker-compose.yml up --build
|
||||
services:
|
||||
app:
|
||||
# no image: field => compose auto-names (docker-app), never pulls,
|
||||
# always builds local. Service image private, never published.
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
args:
|
||||
- REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default}
|
||||
ports:
|
||||
- "${PORT:-8080}:80"
|
||||
volumes:
|
||||
- app-data:/data
|
||||
environment:
|
||||
- DB_PATH=/data/tracker.sqlite
|
||||
- PORT=4001
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# docker/entrypoint.sh — run node backend + caddy proxy in one container.
|
||||
# Caddy foreground (PID 1, handles signals). Node background.
|
||||
set -e
|
||||
|
||||
# node backend (internal :4001)
|
||||
cd /app/server
|
||||
node index.js &
|
||||
NODE_PID=$!
|
||||
|
||||
# caddy proxy (foreground, :80)
|
||||
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
@@ -0,0 +1,274 @@
|
||||
# Development
|
||||
|
||||
TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm workspaces.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- npm 10+
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
/
|
||||
package.json # workspaces root
|
||||
src/ # React frontend (CRA)
|
||||
App.js # main app (~2900 lines)
|
||||
storage/ # adapter layer (firebase/ws/memory + contract)
|
||||
__mocks__/firebase/ # firebase SDK mock (Layer 1 tests)
|
||||
tests/ # frontend tests
|
||||
server/ # Backend: generic KV doc store (firebase mirror)
|
||||
index.js # REST (doc/coll/batch) + WS bootstrap
|
||||
db.js # SQLite docs table, KV ops, broadcast
|
||||
tests/ # backend + adapter-vs-live tests
|
||||
shared/ # Pure logic, no I/O (client + server + tests import)
|
||||
turn.js # turn-order state machine
|
||||
tests/ # turn logic tests
|
||||
scripts/ # manual demo tool (NOT test)
|
||||
replay-combat.js # live backend demo
|
||||
tests/
|
||||
audit/ # exploratory bug-finders (manual, Math.random)
|
||||
audit-rotation.js # rotation invariant
|
||||
audit-state.js # 9 invariant classes
|
||||
scratch/ # gitignored: throwaway repro/exploration
|
||||
docs/
|
||||
REWORK_PLAN.md
|
||||
DEVELOPMENT.md # this file
|
||||
GLOSSARY.md # domain terms (turn vs round, etc)
|
||||
ENCOUNTER_BUILDER.md # DM interface guide
|
||||
TESTING.md # test + automation ops
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone git@github.com:keen99/ttrpg-initiative-tracker.git
|
||||
cd ttrpg-initiative-tracker
|
||||
npm install
|
||||
git config core.hooksPath .githooks # enable pre-push test gate
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
### Backend (dev)
|
||||
|
||||
```bash
|
||||
npm run server:dev # :4001, db: server/data/tracker.sqlite
|
||||
# or direct:
|
||||
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
|
||||
```
|
||||
|
||||
Smoke check:
|
||||
```bash
|
||||
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
||||
```
|
||||
|
||||
Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
|
||||
|
||||
### Frontend (dev server, ws mode)
|
||||
|
||||
```bash
|
||||
REACT_APP_STORAGE=ws \
|
||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||
BROWSER=none PORT=3999 \
|
||||
npm start
|
||||
```
|
||||
|
||||
Opens http://127.0.0.1:3999/. Admin view `/`, player view `/display`.
|
||||
|
||||
Firebase mode (default, upstream): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.example`). `STORAGE_MODE=firebase` falls through to real SDK.
|
||||
|
||||
## Test
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run test:all # shared + server (fast, no frontend)
|
||||
npm run shared:test # pure turn logic
|
||||
npm run server:test # adapter vs live backend
|
||||
npm test # CRA frontend (src/tests/, slow with scenario)
|
||||
```
|
||||
|
||||
### Suites
|
||||
|
||||
| Suite | Location | What | Count |
|
||||
|---|---|---|---|
|
||||
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add | 50 (1 skip) |
|
||||
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 23 |
|
||||
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 62 |
|
||||
| Scenario | `src/tests/Combat.scenario.test.js` | 100-round full combat (240s) | 289 phases |
|
||||
|
||||
Total: 134 green + 1 validated RED (skipped).
|
||||
|
||||
### Test types
|
||||
|
||||
- **Unit** = pure logic, fast, no I/O. Locks behavior of single functions.
|
||||
- **Integration** = real backend per test, adapter translation verified.
|
||||
- **Characterization** = render App via mock, assert current (buggy or not) UI behavior. Not desired-state.
|
||||
- **Scenario** = end-to-end flow through rendered App, asserts full sequence completes.
|
||||
- **Contract** = same spec run against every storage impl (memory, ws, firebase). Catches adapter drift.
|
||||
|
||||
### Running one file / pattern
|
||||
|
||||
```bash
|
||||
npm test --workspace shared -- --testPathPattern=round-rotation
|
||||
CI=true npx react-scripts test --watchAll=false src/tests/App.characterization.test.js
|
||||
```
|
||||
|
||||
### Scenario test is slow
|
||||
|
||||
`Combat.scenario.test.js` runs 100 combat rounds through rendered App — 240s timeout by design. Skip when iterating:
|
||||
|
||||
```bash
|
||||
CI=true npx react-scripts test --watchAll=false --testPathIgnorePatterns="Combat.scenario"
|
||||
```
|
||||
|
||||
## Demo tool (NOT test)
|
||||
|
||||
`scripts/replay-combat.js` = live backend demo. Watch UI react to state changes.
|
||||
|
||||
```bash
|
||||
# start backend + frontend first
|
||||
node scripts/replay-combat.js [rounds] [delayMs]
|
||||
# defaults: 100 rounds, 200ms/step
|
||||
```
|
||||
|
||||
Coverage per round: damage, heal, all 22 conditions, toggleActive,
|
||||
removeParticipant, addParticipant (reinforcements), updateParticipant,
|
||||
pause/resume, reorderParticipants, endEncounter. Revives dead each round
|
||||
to sustain full round count.
|
||||
|
||||
## Audit tools (NOT unit tests)
|
||||
|
||||
`tests/audit/` = exploratory, Math.random, non-deterministic. Manual run.
|
||||
Unit tests (`{shared,server,src}/tests/`) lock known bugs deterministically.
|
||||
|
||||
### audit-rotation.js
|
||||
|
||||
Pure turn.js simulation of replay op sequence. Detects rotation violations
|
||||
(skip/dupe per round). Found BUG-1 (addParticipant + pause corrupts rotation).
|
||||
|
||||
```bash
|
||||
node tests/audit/audit-rotation.js
|
||||
```
|
||||
|
||||
Bisect: comment/uncomment op blocks to isolate triggering combo.
|
||||
|
||||
### audit-state.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)
|
||||
4. turnOrder no dup ids
|
||||
5. turnOrder ids all active
|
||||
6. currentTurn valid + active
|
||||
7. deathSave range (0 ≤ saves ≤ 3, reset on revive)
|
||||
8. removeParticipant orphans
|
||||
9. undo support
|
||||
|
||||
```bash
|
||||
node tests/audit/audit-state.js [rounds] # default 100
|
||||
```
|
||||
|
||||
Current state (post BUG-1/2 fix): 0 violations / 100 rounds.
|
||||
|
||||
See `TODO.md` for known bugs.
|
||||
|
||||
## Scratch
|
||||
|
||||
`scratch/` = gitignored throwaway. Repro scripts, exploration, debug.
|
||||
Not committed. Use freely, delete anytime.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # CRA production build -> build/
|
||||
```
|
||||
|
||||
Docker build (existing, frontend-only):
|
||||
```bash
|
||||
docker build -t ttrpg-initiative-tracker .
|
||||
docker run -p 8080:80 --rm ttrpg-initiative-tracker
|
||||
```
|
||||
|
||||
Full-stack docker-compose arrives in M5.
|
||||
|
||||
## Storage architecture
|
||||
|
||||
### Generic KV doc store
|
||||
|
||||
Backend = firebase mirror. Single `docs` table: `path` (PK), `parent`, `data` (JSON), `updated_at`. Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
|
||||
|
||||
```
|
||||
Client (browser) Server
|
||||
| |
|
||||
|-- storage.setDoc(path,data) -->| REST PUT /api/doc
|
||||
|<---- 200 ----------------------|
|
||||
| |
|
||||
|-- storage.subscribeDoc(path) -->| WS subscribe
|
||||
|<---- WS {initial} --------------| immediate value
|
||||
| ... |
|
||||
|<---- WS {change} --------------| on any write to path
|
||||
| |
|
||||
Display / tablet |
|
||||
|<---- WS {change} --------------| same push
|
||||
```
|
||||
|
||||
### Path normalization
|
||||
|
||||
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Adapter `norm()` strips prefix → bare canonical (`campaigns/...`). All impls share identity (contract test).
|
||||
|
||||
### STORAGE_MODE flow
|
||||
|
||||
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
|
||||
- `firebase` → real SDK init
|
||||
- `ws`/`memory` → stub auth + db sentinel, route via `storage.*` adapter
|
||||
|
||||
## Test layers
|
||||
|
||||
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises ws adapter.
|
||||
- **Layer 2**: ws adapter vs live backend. Proves translation + path identity.
|
||||
|
||||
Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).
|
||||
|
||||
## Local pipeline (pre-push hook)
|
||||
|
||||
Private repo = no free GitHub Actions. Tests run locally via git hook.
|
||||
|
||||
`.githooks/pre-push` runs `npm run test:all` (shared + server, fast). Frontend tests not gated (slow).
|
||||
|
||||
Skip:
|
||||
```bash
|
||||
git push --no-verify
|
||||
```
|
||||
|
||||
Already configured on this checkout after `git config core.hooksPath .githooks`.
|
||||
|
||||
## Status
|
||||
|
||||
| Milestone | State |
|
||||
|---|---|
|
||||
| 0 repo/branch | ✅ done |
|
||||
| 1 backend + tests | ✅ done |
|
||||
| 2 frontend WS adapter | ✅ done |
|
||||
| 3 characterization tests | ✅ done (134 green) |
|
||||
| 4 skip fix + manual override | ⬜ next |
|
||||
| 5 docker compose | ⬜ |
|
||||
| 6 undo rework | ⬜ |
|
||||
| 7 playwright e2e | ⬜ deferred |
|
||||
|
||||
See `docs/REWORK_PLAN.md` for full plan, `TODO.md` for known bugs.
|
||||
|
||||
## Git
|
||||
|
||||
- `origin` = `github.com:keen99/ttrpg-initiative-tracker` (this fork)
|
||||
- `upstream` = `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea, read-only)
|
||||
- work branch: `rework-backend` (off `main`)
|
||||
|
||||
```bash
|
||||
git fetch upstream # pull friend's changes
|
||||
git merge upstream/main # rebase our branch onto his
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
# Encounter Builder — DM Interface Guide
|
||||
|
||||
How a DM (or LLM automating the DM role) builds and runs encounters via the UI and storage layer. Covers entity model, build flow, combat controls, and the storage paths backing each action.
|
||||
|
||||
## Entity model
|
||||
|
||||
Three nested entities. All stored as opaque JSON docs in the KV store (generic doc store — see `docs/DEVELOPMENT.md`).
|
||||
|
||||
```
|
||||
Campaign
|
||||
└─ Encounter(s)
|
||||
└─ Participant(s)
|
||||
```
|
||||
|
||||
Plus two global docs:
|
||||
- `activeDisplay/status` — controls player view (which campaign+encounter, hide-HP flag)
|
||||
- `logs/{id}` — append-only action log entries
|
||||
|
||||
### Campaign
|
||||
|
||||
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string | |
|
||||
| `playerDisplayBackgroundUrl` | string | optional, image URL for player display bg |
|
||||
| `ownerId` | string | user id |
|
||||
| `createdAt` | ISO string | |
|
||||
| `players` | array | campaign-level character roster (templates, NOT combatants) |
|
||||
|
||||
Campaign characters = reusable templates. Default HP + init mod. Added to any encounter via ParticipantManager. Not combatants themselves.
|
||||
|
||||
### Encounter
|
||||
|
||||
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}/encounters/{encounterId}`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string | |
|
||||
| `createdAt` | ISO string | |
|
||||
| `participants` | array | the combatants (see below) |
|
||||
| `round` | int | 0 = not started |
|
||||
| `currentTurnParticipantId` | string\|null | who acts now |
|
||||
| `isStarted` | bool | combat active |
|
||||
| `isPaused` | bool | frozen turn order (add/remove/edit allowed) |
|
||||
| `turnOrderIds` | array | participant ids in turn order = participants[] order (1-list model) |
|
||||
|
||||
### Participant
|
||||
|
||||
Object in `encounter.participants[]`:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | string | `generateId()` |
|
||||
| `name` | string | |
|
||||
| `type` | `'character'` \| `'monster'` | character = PC (death saves), monster = hostile/NPC |
|
||||
| `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) |
|
||||
| `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) |
|
||||
|
||||
## Build flow (UI)
|
||||
|
||||
Admin view at `/`. Steps:
|
||||
|
||||
### 1. Create campaign
|
||||
- Click **Create Campaign** button
|
||||
- Enter name + optional background URL
|
||||
- Submits → `setDoc(campaigns/{id}, { name, playerDisplayBackgroundUrl, ownerId, createdAt, players:[] })`
|
||||
|
||||
### 2. Select campaign
|
||||
- Click campaign card → `setSelectedCampaignId(campaign.id)`
|
||||
- Now managing: CharacterManager + EncounterManager visible
|
||||
|
||||
### 3. Add campaign characters (optional templates)
|
||||
CharacterManager section. Per character:
|
||||
- **Name**
|
||||
- **Default HP** (`DEFAULT_MAX_HP` = 10)
|
||||
- **Init Mod** (`DEFAULT_INIT_MOD` = 0)
|
||||
|
||||
→ `updateDoc(campaign, { players:[...existing, newChar] })`
|
||||
|
||||
These are reusable across encounters. Add to encounter later (auto-rolls initiative).
|
||||
|
||||
### 4. Create encounter
|
||||
- Click **Create Encounter**
|
||||
- Enter name
|
||||
→ `setDoc(campaigns/{cid}/encounters/{eid}, { name, createdAt, participants:[], round:0, currentTurnParticipantId:null, isStarted:false, isPaused:false })`
|
||||
|
||||
### 5. Add participants
|
||||
ParticipantManager section. Two paths:
|
||||
|
||||
**Monster/NPC:**
|
||||
- **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)
|
||||
- Click **Add to Encounter**
|
||||
- Initiative auto-rolled: `rollD20() + mod`
|
||||
|
||||
**Character (from campaign roster):**
|
||||
- Select character from dropdown
|
||||
- Click **Add to Encounter**
|
||||
- OR **Add All (Roll Init)** — bulk-adds all campaign chars, each rolls own initiative
|
||||
|
||||
**Duplicate guard:** same `originalCharacterId` blocked (alerts "already in this encounter"). Monsters no dedup.
|
||||
|
||||
Participant object added:
|
||||
```js
|
||||
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
|
||||
isNpc, conditions:[], isActive:true, deathSaves:0, isDying:false }
|
||||
```
|
||||
|
||||
### 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).
|
||||
|
||||
## Combat flow (UI)
|
||||
|
||||
InitiativeControls panel (sticky, right side).
|
||||
|
||||
### Start
|
||||
- **Start Combat** button (disabled if no active participants)
|
||||
- Sorts ALL participants by initiative (1-list: `participants[]` = display + turn order)
|
||||
- `round=1`, `currentTurnParticipantId` = first active, `isStarted=true`, `isPaused=false`
|
||||
- Sets `activeDisplay` → this campaign+encounter (player display syncs)
|
||||
- Initiative fixed at start. NOT re-derived from mod after.
|
||||
|
||||
### 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
|
||||
|
||||
### Pause / Resume
|
||||
- **Pause Combat** → `isPaused=true`, Next Turn disabled
|
||||
- While paused: add/remove participants, adjust HP, edit initiative, reorder ties
|
||||
- **Resume Combat** → `isPaused=false`, no re-sort (1-list: turnOrderIds already current)
|
||||
|
||||
### HP adjustments (combat only)
|
||||
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
|
||||
|
||||
### Death saves (PC only, at 0 HP)
|
||||
3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal.
|
||||
|
||||
### Conditions
|
||||
- Click participant → expand conditions picker (all 22 from `CONDITIONS`)
|
||||
- Active conditions show as badges, click to remove
|
||||
|
||||
### End combat
|
||||
- **End Combat** button → resets `isStarted:false`, `round:0`, `currentTurn:null`, `turnOrderIds:[]`
|
||||
- Clears `activeDisplay` (player view goes blank)
|
||||
|
||||
## Player display
|
||||
|
||||
Separate view at `/display` or `?playerView=true`. Read-only second screen.
|
||||
|
||||
What it shows:
|
||||
- Current encounter name
|
||||
- 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)
|
||||
|
||||
Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets).
|
||||
|
||||
## 1-list turn order model
|
||||
|
||||
Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single source of truth.
|
||||
|
||||
- **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)
|
||||
- **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).
|
||||
|
||||
## Storage paths quick reference
|
||||
|
||||
```
|
||||
campaigns/{cid} campaign doc
|
||||
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
|
||||
campaigns/{cid}/encounters/{eid}/participants ❌ NOT a path — participants inline
|
||||
activeDisplay/status player display control
|
||||
logs/{logId} action log entry
|
||||
```
|
||||
|
||||
## DM tips
|
||||
|
||||
- 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.
|
||||
- Player display auto-follows Start Combat. Manual control via Open Player Window.
|
||||
|
||||
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Glossary — TTRPG Initiative Tracker
|
||||
|
||||
Domain terms used throughout the app, shared turn logic, tests, and docs. Keep
|
||||
these definitions stable so logs, UI labels, and code agree.
|
||||
|
||||
## Combat structure
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **Initiative** | The ordered sequence determining who acts next. Rolled once at the start of an encounter; re-rolled only on a new encounter or explicit DM re-roll. |
|
||||
| **Round** | One full pass through initiative — every active participant gets exactly one turn. N participants (PCs + NPCs + monsters + features) = N turns per round. Round counter increments when initiative wraps back to the first participant. |
|
||||
| **Turn** | A single participant's initiative slot within a round. One participant acts. As many turns per round as there are participants. |
|
||||
| **Initiative slot** | Synonym for turn's position in the ordered list. |
|
||||
| **Top of round** | The first turn of a round (round counter increments here). |
|
||||
|
||||
Example: encounter with 8 participants (3 PCs + 4 monsters + 1 NPC).
|
||||
|
||||
```
|
||||
Round 1: turn 1 (Fighter) → turn 2 (Goblin1) → ... → turn 8 (Merchant)
|
||||
Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at top]
|
||||
```
|
||||
|
||||
## Participants
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **Participant** | Any combatant tracked in initiative. Has HP, initiative roll, conditions, and an `isActive` flag. |
|
||||
| **PC** (Player Character) | Controlled by a player. On death → death saves (not removed from initiative). |
|
||||
| **NPC** (Non-Player Character) | DM-controlled ally/neutral (e.g. merchant, quest-giver). May or may not roll initiative. |
|
||||
| **Monster** | Hostile DM-controlled combatant. On death → typically removed from active initiative or marked dead. |
|
||||
| **Feature / Lair** | Environmental or legendary effect that occupies an initiative slot (e.g. lair action at initiative 20). |
|
||||
|
||||
## Participant state
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **HP** (Hit Points) | Current health. `currentHp` / `maxHp`. At 0 → dying or dead (rules differ by type). |
|
||||
| **Initiative mod** | Bonus added to d20 initiative roll. `defaultInitMod`. |
|
||||
| **Conditions** | Temporary status effects (stunned, prone, poisoned, etc.) applied/toggled during play. Array on participant. |
|
||||
| **isActive** | Whether the participant is in the active initiative rotation. Set false on death (CURRENT behavior — see M4 skip-bug fix). |
|
||||
| **Death save** | PC-only mechanic. Successes/failures tracked at 0 HP. 3 fails → dead; 3 successes → stable. |
|
||||
|
||||
## Views
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **Admin view** (`/`) | DM interface. Full create/edit/combat control. |
|
||||
| **Player view** (`/display` or `?playerView=true`) | Read-only second-screen display for players. Shows current turn, HP bars, conditions, round. No DM controls. |
|
||||
| **Active display** | The single `activeDisplay/status` doc controlling what the player view shows (which campaign/encounter, hide-player-HP flag). |
|
||||
|
||||
## Backend / storage
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **Adapter** | `src/storage/{firebase,ws,memory}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
|
||||
| **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. |
|
||||
| **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. |
|
||||
| **Layer 1 test** | App vs firebase mock. Proves adapter call shape. |
|
||||
| **Layer 2 test** | ws adapter vs live backend. Proves translation + path identity. |
|
||||
@@ -0,0 +1,266 @@
|
||||
# Initiative Tracker — Rework Plan
|
||||
|
||||
Status: **APPROVED — executing**
|
||||
Owner: draistrick (fork → `keen99/ttrpg-initiative-tracker`, private)
|
||||
Upstream: `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea)
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Replace Firebase with self-hosted backend.** Browser cannot own a DB file (sandbox). Cross-device (DM + tablet + player view) requires a real backend. Backend is the foundation, built first.
|
||||
2. **Automated test ecosystem as the baseline.** Lock current behavior before changing it.
|
||||
3. **Remain mergeable upstream.** Default behavior (Firebase) preserved behind flag. Upstream `main` stays clean. Friend keeps Firebase path.
|
||||
4. **Self-hostable in local Docker** (in-house network). Public exposure = future, only after auth + multiuser safety.
|
||||
|
||||
## Non-Goals (this plan)
|
||||
|
||||
- Ripping Firebase. Kept as default adapter upstream.
|
||||
- Public/multiuser deployment. Deferred.
|
||||
- Rewriting the entire 2935-line `App.js`. Only extract what testability demands.
|
||||
- Feature/bug work. That lives in `TODO.md`. This plan = infra + backend + test harness only.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Why Firebase is wrong here (for this fork)
|
||||
- Requires Google account + network for a single-user tabletop tool.
|
||||
- Realtime value (DM view ↔ player display) is real but solvable locally.
|
||||
- API key baked into client bundle (CRA `REACT_APP_*` at build); security depends entirely on console rules not in repo.
|
||||
- Vendor lock + quota; `onSnapshot` on collections burns reads.
|
||||
- Friend keeps it; we fork off it.
|
||||
|
||||
### Why a backend is mandatory
|
||||
Browser sandbox cannot write the filesystem. No sqlite file, no `/data/db.sqlite`, nothing. Browser JS is blocked from disk by design. Therefore cross-device storage (DM ↔ tablet ↔ player view) requires a separate Node process owning the DB file and serving the browser over HTTP/WebSocket. There is no browser-only path. **The backend is step one, not deferred.**
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack (locked)
|
||||
- **Node.js** runtime
|
||||
- **Express** web framework
|
||||
- **ws** WebSocket lib (realtime push, replaces `onSnapshot`)
|
||||
- **better-sqlite3** SQLite driver (synchronous, simple, fast)
|
||||
- **SQLite** DB (single file, docker volume, trivial backup)
|
||||
- **Jest** test runner (already in CRA deps)
|
||||
|
||||
Postgres deferred until public multiuser exposure is real. SQLite schema ports easily if that day comes.
|
||||
|
||||
### Backend design
|
||||
- Owns SQLite file. Only writer.
|
||||
- Holds authoritative state.
|
||||
- Generic KV doc store (firebase mirror): single `docs` table (path PK, parent, data JSON, updated_at). Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
|
||||
- WS broadcast on every state change → all connected clients (DM view, player display, tablet) update instantly.
|
||||
|
||||
### Three storage impls, one interface (frontend)
|
||||
|
||||
The storage interface is the test seam and the upstream-compat layer.
|
||||
|
||||
| Impl | When used | Automated-tested? |
|
||||
|---|---|---|
|
||||
| `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project |
|
||||
| `ws.js` | `STORAGE=ws` — our fork, talks to backend | Yes — against running backend |
|
||||
| `memory.js` | test-only, in-process | Yes — fast, deterministic |
|
||||
|
||||
**Frontend interface contract** (all three implement):
|
||||
- `getDoc(path)`, `setDoc(path, data, opts)`, `updateDoc(path, patch)`
|
||||
- `deleteDoc(path)`, `batch(ops)`
|
||||
- `subscribeDoc(path, cb)` / `subscribeCollection(path, cb)` → real-time push
|
||||
|
||||
Firebase impl: existing `onSnapshot` + SDK calls, moved verbatim behind interface (M2).
|
||||
WS impl: thin adapter; generic KV ops, receives **state updates** via WS subscribe (M2).
|
||||
Memory impl: in-memory Map + EventEmitter, for tests (M3).
|
||||
|
||||
### Repo layout (npm workspaces)
|
||||
|
||||
```
|
||||
/
|
||||
package.json # workspaces root
|
||||
src/ # React frontend (existing, refactored behind storage interface)
|
||||
storage/
|
||||
index.js # factory: pick impl from STORAGE env
|
||||
firebase.js # extracted from current App.js (verbatim)
|
||||
ws.js # NEW — talks to backend
|
||||
memory.js # NEW — test only
|
||||
contract.js # interface spec (runStorageContract)
|
||||
tests/ # frontend tests
|
||||
server/ # NEW
|
||||
index.js # Express + ws bootstrap, generic KV REST
|
||||
db.js # better-sqlite3, docs table (KV), broadcast
|
||||
handlers.js # REST handlers
|
||||
tests/ # adapter vs live backend (Layer 2 test)
|
||||
shared/ # pure logic, no I/O, importable by client + server + tests
|
||||
turn.js # turn logic (single source; tests import)
|
||||
tests/ # turn logic unit tests (characterization + desired)
|
||||
data/ # gitignored sqlite DB
|
||||
docker-compose.yml # NEW — M5
|
||||
docs/
|
||||
REWORK_PLAN.md # this file
|
||||
DEVELOPMENT.md
|
||||
GLOSSARY.md
|
||||
TODO.md # bugs + features (separate from this plan)
|
||||
```
|
||||
|
||||
### Auth
|
||||
- **Now:** `AUTH_MODE=none`. App gated by nginx HTTP basic auth (reuse friend's existing pattern). In-house only. Risk acceptable: someone sees your initiative counter.
|
||||
- **Future:** `AUTH_MODE=token` — real login, real users. Only if/when publicly exposed. Not built this plan.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||
|
||||
| M | Does | Tests? |
|
||||
|---|---|---|
|
||||
| 0 | repo, branch, remotes | no |
|
||||
| 1 | build backend (Node+Express+ws+better-sqlite3) | unit tests as built |
|
||||
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
|
||||
| 3 | characterization tests lock current behavior | yes |
|
||||
| 4 | resolve initiative rotation corruption (BUG-5) | yes |
|
||||
| 5 | docker single container (caddy+node) | smoke ✅ |
|
||||
| 6 | _moved to TODO backlog (feature work)_ | - |
|
||||
| 7 | playwright multi-window e2e (deferred) | e2e |
|
||||
| 8 | (future) public exposure | - |
|
||||
|
||||
### Milestone 0 — Repo + branch setup ✅
|
||||
- Fresh branch off `main` (not `dsr-rework`). Name: `rework-backend`.
|
||||
- `upstream` remote = friend's Gitea (read-only fetch).
|
||||
- Push origin = `keen99/ttrpg-initiative-tracker` (private).
|
||||
- npm workspaces root config.
|
||||
- Commit this plan.
|
||||
- **Exit criteria:** clean branch, plan committed, remotes set. ✅ DONE.
|
||||
- **Upstream-PRable:** n/a (fork infra)
|
||||
|
||||
### Milestone 1 — Build backend ✅
|
||||
- `server/`: Express + ws + better-sqlite3.
|
||||
- Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path.
|
||||
- Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`).
|
||||
- **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE.
|
||||
- **Upstream-PRable:** ❌ divergence (friend stays Firebase).
|
||||
|
||||
### Milestone 2 — Frontend WS adapter ✅
|
||||
- Define `storage/contract.js` interface spec.
|
||||
- Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim).
|
||||
- Implement `storage/ws.js` per interface, talking to backend. Generic KV ops, subscribes to WS.
|
||||
- Implement `storage/memory.js` for frontend unit tests.
|
||||
- `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged).
|
||||
- App runs against backend with `STORAGE=ws`.
|
||||
- Cross-device verified manually: DM view + player display + tablet.
|
||||
- **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE.
|
||||
- **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌.
|
||||
|
||||
### Milestone 3 — Characterization tests lock current behavior ✅
|
||||
- Lock current behavior via tests.
|
||||
- Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END.
|
||||
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (ws adapter vs live backend, proves translation).
|
||||
- Iterate until confident: baseline solid, regressions impossible to silently slip.
|
||||
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
|
||||
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
|
||||
|
||||
### Milestone 4 — Resolve initiative rotation corruption (BUG-5) ✅
|
||||
- **Fixed** (commit `494327f`).
|
||||
- Slot-array turn order model + DRY advance core (`nextActiveAfter`).
|
||||
Both `nextTurn` + `computeTurnOrderAfterRemoval` delegate → one advance
|
||||
path, no drift.
|
||||
- 500-round replay: 0 skips, 0 double-acts.
|
||||
- Tests: `turn.skip.test.js`, `turn.dry.test.js` (advance parity lock).
|
||||
- **Upstream-PRable:** ✅ bug fix.
|
||||
|
||||
### Milestone 5 — Docker compose ✅
|
||||
- Single container: caddy (front, static + proxy) + node backend (internal :4001).
|
||||
- Files in `docker/` tree (kept separate from upstream root Dockerfile):
|
||||
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
|
||||
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
|
||||
- `docker/entrypoint.sh` — node bg + caddy fg
|
||||
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
|
||||
- Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080.
|
||||
- No `image:` field => compose auto-names, never pulls service image (private).
|
||||
- **Exit criteria:** `docker compose up` runs full stack in-house. ✅ DONE.
|
||||
- Verified: REST roundtrip, WS subscribe+push, replay 20 rounds CLEAN (0 skips/doubles/shifts), UI styled (Tailwind compiles).
|
||||
- **Upstream-PRable:** ✅ separate docker/ tree, root Dockerfile untouched, firebase default preserved.
|
||||
|
||||
### Milestone 6 — Undo rework — _MOVED to TODO backlog_
|
||||
- Moved: feature work (transactional undo), not infra. Lives in `TODO.md` now.
|
||||
- Scope: events table `(type, payload, undo_payload, undone, ts)`; undo = apply undo_payload in tx.
|
||||
|
||||
### Milestone 7 — Playwright E2E (deferred)
|
||||
- Multi-window E2E: DM view + display + player view in separate browser contexts against running backend.
|
||||
- Verify realtime sync end-to-end.
|
||||
- **Only build if sync regresses or we deviate significantly.** Turn-logic unit + backend integration tests cover most regression risk cheaper.
|
||||
- **Exit criteria:** e2e green for core combat flow across 3 windows.
|
||||
- **Upstream-PRable:** ✅ if test infra shared.
|
||||
|
||||
### Milestone 8 — (Future) Public exposure
|
||||
- Real auth (`AUTH_MODE=token`).
|
||||
- Rate limiting, CSRF, hardening.
|
||||
- Postgres migration if load warrants.
|
||||
- Only if we decide to expose publicly + multiuser.
|
||||
|
||||
---
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Layers
|
||||
1. **Turn logic unit tests** (Jest, pure functions, `shared/tests/`). Characterization + desired. Cheap, essential.
|
||||
2. **Backend integration tests** (Jest, `server/tests/`) — spin server on random port, assert WS pushes + SQLite persists + transactional correctness.
|
||||
3. **Frontend adapter contract tests** (Jest, `src/tests/`) — impl parity against interface (memory). Firebase mock harness for Layer 1 App tests.
|
||||
|
||||
### Characterization → desired
|
||||
1. **Characterization** — capture current behavior exactly (bugs included). Locks extraction/port as provably identical. Lets later fix be provable.
|
||||
2. **Desired-behavior (red)** — write what *should* happen. Fail today. Fix → green. Bug stays dead. (Bug fixes live in TODO.md, tracked separately.)
|
||||
|
||||
### Manual smoke via config flags
|
||||
- `STORAGE=firebase` → current behavior (friend's path, upstream default).
|
||||
- `STORAGE=ws` → our path, local backend.
|
||||
- docker-compose profiles mirror the above.
|
||||
|
||||
### Accepted test gap
|
||||
- Firebase adapter untested (requires live project). Accepted cost.
|
||||
- Mitigated by: interface contract; if firebase impl drifts, integration smoke only.
|
||||
|
||||
---
|
||||
|
||||
## Mergeability upstream
|
||||
|
||||
| Milestone | Upstream-PRable? | Why |
|
||||
|---|---|---|
|
||||
| 0 repo setup | n/a | fork infra |
|
||||
| 1 backend | ❌ | divergence (friend stays Firebase) |
|
||||
| 2 WS adapter | ⚠️ partial | interface + firebase extract ✅, WS ❌ |
|
||||
| 3 characterization tests | ✅ | if storage-agnostic |
|
||||
| 4 BUG-5 rotation fix | ✅ | bug fix |
|
||||
| 5 docker | ✅ | separate docker/ tree, root Dockerfile untouched, firebase preserved |
|
||||
| 6 undo (moved to TODO) | - | - |
|
||||
| 7 playwright | ✅ | if test infra shared |
|
||||
|
||||
Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally zero change.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- **CRA + workspaces friction.** Create React App may resist monorepo layout. Mitigation: keep `src/` as CRA root, `server/` + `shared/` as separate workspaces imported via alias. Eject/craco only if forced.
|
||||
- **Firebase drift untested.** Mitigation: interface contract; friend's path his to maintain.
|
||||
- **Undo history migration.** Existing log entries use old snapshot format. Mitigation: keep old undo working until cleared, new format for new entries.
|
||||
- **WS reconnect/state-sync edge cases.** Transient drop mid-combat. Mitigation: client requests full state resync on (re)connect; server is source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Branch:** `rework-backend` off `main`.
|
||||
2. **npm workspaces** for `server/` + `shared/` alongside CRA `src/`. Fallback alias if CRA fights.
|
||||
3. **Backend = generic KV doc store** (firebase mirror), not shape-specific endpoints. Thin adapter passthrough. Opaque JSON at arbitrary path strings.
|
||||
|
||||
---
|
||||
|
||||
## Current status
|
||||
|
||||
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
|
||||
- Backend live: port 4001, db `./data/tracker.sqlite`
|
||||
- Frontend: port 3999 with `REACT_APP_STORAGE=ws`
|
||||
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
|
||||
- Next milestones: M5 docker-compose. Undo moved to TODO backlog.
|
||||
@@ -0,0 +1,234 @@
|
||||
# Testing & Automation — Operating Guide
|
||||
|
||||
How to run tests, demos, audits, docker stack, and understand the test layers. For any LLM session picking up this repo.
|
||||
|
||||
## Test commands
|
||||
|
||||
```bash
|
||||
npm run test:all # shared + server (fast, ~2s) — pre-push gate
|
||||
npm run shared:test # pure turn logic (shared/turn.js)
|
||||
npm run server:test # ws adapter vs live backend
|
||||
npm test # CRA frontend (src/tests/, slow w/ scenario)
|
||||
```
|
||||
|
||||
Pre-push hook (`.githooks/pre-push`) runs `npm run test:all`. Frontend not gated (slow). Skip: `git push --no-verify`.
|
||||
|
||||
Setup hook once per clone:
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
## Test suites
|
||||
|
||||
| Suite | Location | What | Count |
|
||||
|---|---|---|---|
|
||||
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add, dead-skip, reorder, round, invariant, dry | 90 |
|
||||
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 24 |
|
||||
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 66 |
|
||||
| ESM guard | `src/tests/StorageEsm.test.js` | no CJS in adapters | 4 |
|
||||
|
||||
Total: ~184. 1 known RED (BUG-4 HideHpToggle, backlog).
|
||||
|
||||
### Run one file / pattern
|
||||
|
||||
```bash
|
||||
npm test --workspace shared -- --testPathPattern=round-rotation
|
||||
npm run server:test -- tests/ws-reconnect
|
||||
CI=true npx react-scripts test --watchAll=false --testPathPattern="DisplayView.drag-order"
|
||||
```
|
||||
|
||||
Frontend uses `react-scripts test` (CRA). Always set `CI=true` + `--watchAll=false` for single runs.
|
||||
|
||||
## Test layers (important)
|
||||
|
||||
Two layers, both required:
|
||||
|
||||
- **Layer 1**: App vs firebase mock (`src/__mocks__/firebase/`). Proves adapter call shape. Never exercises ws adapter.
|
||||
- **Layer 2**: ws adapter vs live backend (`server/tests/`). Proves translation + path identity.
|
||||
|
||||
Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws event handler bugs). Layer 2 catches those.
|
||||
|
||||
## Test types
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| **Unit** | pure logic, fast, no I/O. Locks single function behavior. |
|
||||
| **Integration** | real backend per test (port 0 = OS picks free). Adapter translation verified. |
|
||||
| **Characterization** | render App via mock, assert current UI behavior (buggy or not). NOT desired-state. |
|
||||
| **Contract** | same spec run against every storage impl (memory, ws, firebase). Catches adapter drift. |
|
||||
| **Scenario** | end-to-end flow through rendered App. `Combat.scenario.test.js` = 100 rounds, ~240s. Pre-existing crash (BUG-11). |
|
||||
|
||||
## TDD discipline
|
||||
|
||||
RED first → fix → GREEN. Never change functional code to pass tests for existing state without test driving it.
|
||||
|
||||
- Find bug → write failing test (RED)
|
||||
- Fix code → test passes (GREEN)
|
||||
- Log confirmed bug in `TODO.md`
|
||||
- One bug at a time, commit with evidence
|
||||
|
||||
## Replay tool (demo, NOT unit test)
|
||||
|
||||
`scripts/replay-combat.js` — drives full combat via ws adapter (same contract as App) against live backend. UI updates in real-time if frontend running.
|
||||
|
||||
```bash
|
||||
# start backend + frontend first
|
||||
node scripts/replay-combat.js [rounds] [delayMs]
|
||||
# defaults: 100 rounds, 200ms/step
|
||||
# faster: 20 400 = 20 rounds, 400ms each
|
||||
|
||||
# against docker stack:
|
||||
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400
|
||||
```
|
||||
|
||||
Coverage per round: damage, heal, all 22 conditions, toggleActive, removeParticipant, addParticipant (reinforcements), updateParticipant, pause/resume, reorderParticipants, endEncounter. Revives dead each round to sustain count.
|
||||
|
||||
Output → log file, then analyze:
|
||||
|
||||
```bash
|
||||
node scripts/replay-combat.js 20 400 > tmp/run.log 2>&1
|
||||
node scripts/analyze-turns.js tmp/run.log
|
||||
```
|
||||
|
||||
Exit 0 = clean. Reports skips, double-acts, order shifts.
|
||||
|
||||
### analyze-turns.js
|
||||
|
||||
Parses replay log. Detects:
|
||||
- **real skips**: active participant not acted in a round
|
||||
- **double-acts**: same participant twice in a round
|
||||
- **order shifts**: turnOrderIds changed unexpectedly
|
||||
|
||||
Handles `[pointer X→Y wrap]` events (mutation-driven advance) and `[reorder A→before B]`. Logs `order=[Name:init,...]` + `parts=[Name:init,...]` per turn. Parser blind to DisplayView render (separate concern — FE test covers that).
|
||||
|
||||
Round marker: `--- round N starting ---` (top of loop, post-fix).
|
||||
|
||||
## Audit tools (NOT unit tests)
|
||||
|
||||
`tests/audit/` — exploratory, `Math.random`, non-deterministic. Manual run. NOT jest.
|
||||
|
||||
### audit-rotation.js
|
||||
Pure turn.js simulation of replay op sequence. Detects rotation violations. Found BUG-1.
|
||||
|
||||
```bash
|
||||
node tests/audit/audit-rotation.js
|
||||
```
|
||||
|
||||
### audit-state.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)
|
||||
4. turnOrder no dup ids
|
||||
5. turnOrder ids all active
|
||||
6. currentTurn valid + active
|
||||
7. deathSave range (0-3, reset on revive)
|
||||
8. removeParticipant orphans
|
||||
9. undo support
|
||||
|
||||
```bash
|
||||
node tests/audit/audit-state.js [rounds] # default 100
|
||||
```
|
||||
|
||||
Current state: 0 violations / 100 rounds (post BUG-1/2 fix).
|
||||
|
||||
## Docker stack
|
||||
|
||||
Single container: caddy (front, static + proxy) + node backend (internal :4001).
|
||||
|
||||
```bash
|
||||
# build + run (from repo root)
|
||||
docker compose -f docker/docker-compose.yml up --build -d
|
||||
# → http://127.0.0.1:8080
|
||||
|
||||
# logs
|
||||
docker compose -f docker/docker-compose.yml logs app --tail 20
|
||||
|
||||
# stop
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
|
||||
# rebuild after code change
|
||||
docker compose -f docker/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Files:
|
||||
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
|
||||
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
|
||||
- `docker/entrypoint.sh` — runs node bg + caddy fg
|
||||
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
|
||||
|
||||
### Verify docker stack
|
||||
|
||||
```bash
|
||||
# REST roundtrip
|
||||
curl -s -X PUT http://127.0.0.1:8080/api/doc -H 'Content-Type: application/json' \
|
||||
-d '{"path":"campaigns/test","data":{"name":"X"}}' >/dev/null
|
||||
curl -s "http://127.0.0.1:8080/api/doc?path=campaigns/test"
|
||||
|
||||
# WS subscribe + push (node one-liner, see scripts)
|
||||
# Full combat: replay against docker
|
||||
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400 > tmp/docker.log 2>&1
|
||||
node scripts/analyze-turns.js tmp/docker.log
|
||||
```
|
||||
|
||||
### Inspect docker sqlite
|
||||
|
||||
```bash
|
||||
docker exec docker-app-1 sh -c 'node -e "
|
||||
const db=require(\"better-sqlite3\")(\"/data/tracker.sqlite\");
|
||||
const rows=db.prepare(\"SELECT path, substr(data,1,50) as d FROM docs\").all();
|
||||
console.log(\"count=\"+rows.length);
|
||||
rows.forEach(r=>console.log(r.path+\" => \"+r.d));
|
||||
"'
|
||||
```
|
||||
|
||||
## Dev servers (non-docker)
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
npm run server:dev # :4001, db: ./data/tracker.sqlite
|
||||
# or:
|
||||
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
|
||||
curl http://127.0.0.1:4001/health # → {"ok":true}
|
||||
```
|
||||
|
||||
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
|
||||
|
||||
### Frontend (ws mode)
|
||||
```bash
|
||||
REACT_APP_STORAGE=ws \
|
||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||
BROWSER=none PORT=3999 \
|
||||
npm start
|
||||
```
|
||||
→ http://127.0.0.1:3999/. Admin `/`, player `/display`.
|
||||
|
||||
Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.example`).
|
||||
|
||||
## Storage modes
|
||||
|
||||
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
|
||||
- `firebase` (default) → real SDK
|
||||
- `ws` → backend (docker/prod)
|
||||
- `memory` → in-process (test seed)
|
||||
|
||||
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs memory/ws/firebase.
|
||||
|
||||
## Known RED / backlog
|
||||
|
||||
- BUG-4: HideHpToggle RED (setDoc→updateDoc, clobbers activeDisplay)
|
||||
- BUG-10: deact+reactivate double-act
|
||||
- BUG-11: Combat.scenario test crash
|
||||
- BUG-13: reorder cross-pointer semantics
|
||||
- BUG-14: addParticipant init-insert post-drag
|
||||
|
||||
See `TODO.md` for full list + status.
|
||||
|
||||
## Scratch
|
||||
|
||||
`scratch/` — gitignored throwaway. Repro scripts, exploration, debug. Not committed. Use freely, delete anytime.
|
||||
|
||||
## Status
|
||||
|
||||
See `docs/REWORK_PLAN.md` for milestones, `TODO.md` for bugs, `docs/DEVELOPMENT.md` for setup, `docs/GLOSSARY.md` for terms, `docs/ENCOUNTER_BUILDER.md` for DM interface.
|
||||
|
After Width: | Height: | Size: 658 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 159 KiB |
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,33 @@
|
||||
"name": "ttrpg-initiative-tracker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
"shared"
|
||||
],
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"firebase": "^10.12.2",
|
||||
"lucide-react": "^0.395.0",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3"
|
||||
"tailwindcss": "^3.4.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"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": "npm run shared:test && npm run server:test"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Alegreya+Sans:ital,wght@0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<title>TTRPG Initiative Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# scripts/
|
||||
|
||||
Manual demo tool. NOT test.
|
||||
|
||||
## replay-combat.js
|
||||
|
||||
Live backend demo. Drives full combat via ws adapter (same contract as App).
|
||||
Player display live-updates. Watch UI react to state changes.
|
||||
|
||||
```bash
|
||||
# start backend + frontend first (see docs/DEVELOPMENT.md)
|
||||
node scripts/replay-combat.js [rounds] [delayMs]
|
||||
# defaults: 100 rounds, 200ms/step
|
||||
```
|
||||
|
||||
Coverage per round: damage, heal, all 22 conditions, toggleActive,
|
||||
removeParticipant, addParticipant (reinforcements), updateParticipant,
|
||||
pause/resume, reorderParticipants, endEncounter. Revives dead each round
|
||||
to sustain full round count.
|
||||
|
||||
## See also
|
||||
|
||||
- `tests/audit/` — exploratory bug-finders (manual run, non-deterministic)
|
||||
- `{shared,server,src}/tests/` — jest unit/integration/characterization
|
||||
- `scratch/` — gitignored throwaway
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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().
|
||||
|
||||
'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 ----------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
process.exit(clean ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start local dev stack: node backend (sqlite) + react frontend, ws storage mode.
|
||||
# Usage: ./scripts/dev-start.sh
|
||||
# Stop: ./scripts/dev-stop.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p tmp data
|
||||
|
||||
# kill anything on the ports (zombies)
|
||||
for port in 3999 4001; do
|
||||
pids=$(lsof -ti :$port 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
echo "port $port in use by: $pids — leaving as-is."
|
||||
echo " (run ./scripts/dev-stop.sh first to restart clean)"
|
||||
fi
|
||||
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 \
|
||||
nohup npm run server:dev > tmp/server.log 2>&1 &
|
||||
echo $! > tmp/server.pid
|
||||
else
|
||||
echo "backend already on :4001"
|
||||
fi
|
||||
|
||||
# frontend: ws storage, :3999
|
||||
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
||||
echo "starting frontend :3999..."
|
||||
REACT_APP_STORAGE=ws \
|
||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||
BROWSER=none PORT=3999 \
|
||||
nohup npm start > tmp/fe.log 2>&1 &
|
||||
echo $! > tmp/fe.pid
|
||||
else
|
||||
echo "frontend already on :3999"
|
||||
fi
|
||||
|
||||
# wait for ports to listen
|
||||
echo "waiting for ports..."
|
||||
for port in 4001 3999; do
|
||||
for i in {1..30}; do
|
||||
lsof -ti :$port >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
|
||||
echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
|
||||
echo "logs : tmp/server.log tmp/fe.log"
|
||||
echo "stop : ./scripts/dev-stop.sh"
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop local dev stack. Usage: ./scripts/dev-stop.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
stopped=0
|
||||
for port in 3999 4001; do
|
||||
pids=$(lsof -ti :$port 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
echo "stopping :$port (pid: $pids)"
|
||||
kill $pids 2>/dev/null || true
|
||||
stopped=1
|
||||
fi
|
||||
done
|
||||
|
||||
# also kill recorded pids
|
||||
for f in tmp/server.pid tmp/fe.pid; do
|
||||
if [ -f "$f" ]; then
|
||||
pid=$(cat "$f")
|
||||
kill "$pid" 2>/dev/null || true
|
||||
rm -f "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# node --watch spawns children — sweep by port pattern
|
||||
pids=$(pgrep -f "node --watch index.js|react-scripts start" 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
echo "sweeping node dev procs: $pids"
|
||||
kill $pids 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ "$stopped" = "0" ]; then
|
||||
echo "nothing running."
|
||||
fi
|
||||
echo "stopped."
|
||||
@@ -0,0 +1,376 @@
|
||||
// scripts/replay-combat.js
|
||||
// Drive a full combat through the LIVE backend via the ws 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)
|
||||
//
|
||||
// Run: node scripts/replay-combat.js [rounds] [delayMs]
|
||||
// rounds default 100, delayMs default 200
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('../shared');
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
const { createWsStorage } = require('../src/storage/ws');
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||
const DELAY = parseInt(process.argv[3], 10) || 200;
|
||||
|
||||
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`,
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// Use the ADAPTER as the contract boundary (same as App). No raw REST.
|
||||
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL });
|
||||
|
||||
// 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',
|
||||
];
|
||||
|
||||
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 pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
|
||||
async function main() {
|
||||
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
|
||||
|
||||
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();
|
||||
|
||||
// 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 totalTurns = 0;
|
||||
const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5);
|
||||
let reinforcementsAdded = 0;
|
||||
let lastPaused = false;
|
||||
let lastReorder = 0;
|
||||
|
||||
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).
|
||||
|
||||
// 9. resume if paused: must happen BEFORE nextTurn or it throws.
|
||||
if (lastPaused) {
|
||||
enc = await patch(encounterPath, enc, togglePause(enc), 'resume');
|
||||
lastPaused = false;
|
||||
}
|
||||
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
try {
|
||||
const c = toggleCondition(enc, tgt.id, cond);
|
||||
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. toggleParticipantActive: randomly mark someone inactive, or reactivate.
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 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 */ }
|
||||
}
|
||||
|
||||
// 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'));
|
||||
if (dead) {
|
||||
try {
|
||||
const r = removeParticipant(enc, dead.id);
|
||||
enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (!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 (d.isActive === false) {
|
||||
enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
||||
}
|
||||
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}`); }
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`);
|
||||
|
||||
// end
|
||||
enc = await storage.getDoc(encounterPath);
|
||||
if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter');
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||
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,3 @@
|
||||
module.exports = {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
// server/db.js — generic KV document store on SQLite.
|
||||
// Mirrors Firestore doc-tree model: every doc lives at a string path.
|
||||
// Collections are implicit = all docs whose parent path equals the collection path.
|
||||
//
|
||||
// Path examples (canonical, prefix already stripped by adapter):
|
||||
// campaigns/{id} doc
|
||||
// campaigns/{cid}/encounters/{eid} doc
|
||||
// campaigns/{cid}/encounters collection (parent of encounter docs)
|
||||
// activeDisplay/status doc
|
||||
// logs/{id} doc
|
||||
//
|
||||
// No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
|
||||
// the adapter (src/storage/ws.js) is a thin passthrough, app logic unchanged.
|
||||
|
||||
'use strict';
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS docs (
|
||||
path TEXT PRIMARY KEY,
|
||||
parent TEXT,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
|
||||
`;
|
||||
|
||||
function openDb(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
// parentOf('campaigns/abc/encounters/xyz') => 'campaigns/abc/encounters'
|
||||
// parentOf('campaigns') => null (root-level doc, no parent collection tracked)
|
||||
function parentOf(p) {
|
||||
const i = p.lastIndexOf('/');
|
||||
return i === -1 ? null : p.slice(0, i);
|
||||
}
|
||||
|
||||
function makeStore(db, broadcast) {
|
||||
const stmtGet = db.prepare('SELECT data FROM docs WHERE path = ?');
|
||||
const stmtUpsert = db.prepare(`
|
||||
INSERT INTO docs (path, parent, data, updated_at) VALUES (@path, @parent, @data, @ts)
|
||||
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);
|
||||
return row ? JSON.parse(row.data) : null;
|
||||
}
|
||||
|
||||
function setDoc(p, data) {
|
||||
const ts = Date.now();
|
||||
stmtUpsert.run({ path: p, parent: parentOf(p), data: JSON.stringify(data), ts });
|
||||
if (broadcast) broadcast({ path: p, parent: parentOf(p) });
|
||||
return data;
|
||||
}
|
||||
|
||||
// shallow merge; if doc missing, patch becomes the doc (matches firebase updateDoc create-on-miss)
|
||||
function updateDoc(p, patch) {
|
||||
const existing = getDoc(p) || {};
|
||||
const merged = { ...existing, ...patch };
|
||||
setDoc(p, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function deleteDoc(p) {
|
||||
stmtDelete.run(p);
|
||||
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 batchWrite(ops) {
|
||||
const run = db.transaction((items) => {
|
||||
const changed = [];
|
||||
for (const op of items) {
|
||||
if (op.type === 'set') {
|
||||
setDoc(op.path, op.data);
|
||||
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||
} else if (op.type === 'delete') {
|
||||
deleteDoc(op.path);
|
||||
changed.push({ path: op.path, parent: parentOf(op.path), deleted: true });
|
||||
} else if (op.type === 'update') {
|
||||
updateDoc(op.path, op.data);
|
||||
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
const changed = run(ops);
|
||||
if (broadcast) changed.forEach(c => broadcast(c));
|
||||
}
|
||||
|
||||
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, batchWrite };
|
||||
}
|
||||
|
||||
module.exports = { openDb, parentOf, makeStore };
|
||||
@@ -0,0 +1,152 @@
|
||||
// server/index.js — generic KV document store over HTTP + WebSocket.
|
||||
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
|
||||
// Adapter (src/storage/ws.js) = passthrough, no shape translation.
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { openDb, makeStore } = require('./db');
|
||||
|
||||
function createServer({ dbPath, port, corsOrigin } = {}) {
|
||||
const db = openDb(dbPath || './data/tracker.sqlite');
|
||||
const app = express();
|
||||
app.use(cors({ origin: corsOrigin || '*' }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// WS subscribers: path -> Set<ws>.
|
||||
// Subscribers register a path (doc or collection). On change, notify:
|
||||
// - doc subscribers at the changed path
|
||||
// - collection subscribers at the changed doc's parent path
|
||||
const docSubscribers = new Map(); // path -> Set<ws>
|
||||
const collSubscribers = new Map(); // collPath -> Set<ws>
|
||||
|
||||
function addSub(map, key, ws) {
|
||||
if (!map.has(key)) map.set(key, new Set());
|
||||
map.get(key).add(ws);
|
||||
}
|
||||
function removeSub(map, key, ws) {
|
||||
const set = map.get(key);
|
||||
if (set) { set.delete(ws); if (set.size === 0) map.delete(key); }
|
||||
}
|
||||
function dropWs(ws) {
|
||||
for (const [key, set] of docSubscribers) { set.delete(ws); if (set.size === 0) docSubscribers.delete(key); }
|
||||
for (const [key, set] of collSubscribers) { set.delete(ws); if (set.size === 0) collSubscribers.delete(key); }
|
||||
}
|
||||
|
||||
function broadcast(change) {
|
||||
const payload = JSON.stringify({ type: 'change', change });
|
||||
// doc subscriber at exact path
|
||||
const docSet = docSubscribers.get(change.path);
|
||||
if (docSet) [...docSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||
// collection subscribers at parent path (collection contains this doc)
|
||||
if (change.parent) {
|
||||
const collSet = collSubscribers.get(change.parent);
|
||||
if (collSet) [...collSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||
}
|
||||
}
|
||||
|
||||
const store = makeStore(db, broadcast);
|
||||
|
||||
// --- generic REST ---
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
// GET /api/doc?path=campaigns/abc/encounters/xyz
|
||||
app.get('/api/doc', (req, res) => {
|
||||
const { path: p } = req.query;
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
res.json({ path: p, data: store.getDoc(p) });
|
||||
});
|
||||
|
||||
// GET /api/collection?path=campaigns/abc/encounters
|
||||
app.get('/api/collection', (req, res) => {
|
||||
const { path: p } = req.query;
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
res.json(store.getCollection(p));
|
||||
});
|
||||
|
||||
// PUT /api/doc body: { path, data } (replace)
|
||||
app.put('/api/doc', (req, res) => {
|
||||
const { path: p, data } = req.body || {};
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
res.json({ path: p, data: store.setDoc(p, data) });
|
||||
});
|
||||
|
||||
// PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss)
|
||||
app.patch('/api/doc', (req, res) => {
|
||||
const { path: p, patch } = req.body || {};
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
res.json({ path: p, data: store.updateDoc(p, patch) });
|
||||
});
|
||||
|
||||
// DELETE /api/doc?path=...
|
||||
app.delete('/api/doc', (req, res) => {
|
||||
const { path: p } = req.query;
|
||||
if (!p) return res.status(400).json({ error: 'path required' });
|
||||
store.deleteDoc(p);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/collection body: { path, data } (addDoc: auto-id under collection)
|
||||
app.post('/api/collection', (req, res) => {
|
||||
const { path: collPath, data } = req.body || {};
|
||||
if (!collPath) return res.status(400).json({ error: 'path required' });
|
||||
const id = crypto.randomUUID();
|
||||
const docPath = `${collPath}/${id}`;
|
||||
res.json({ id, path: docPath, data: store.setDoc(docPath, data) });
|
||||
});
|
||||
|
||||
// POST /api/batch body: { ops: [{type:'set'|'update'|'delete', path, data?}] }
|
||||
app.post('/api/batch', (req, res) => {
|
||||
const { ops } = req.body || {};
|
||||
if (!Array.isArray(ops)) return res.status(400).json({ error: 'ops array required' });
|
||||
store.batchWrite(ops);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- WebSocket: subscribe by path ---
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
|
||||
if (msg.type === 'subscribe' && msg.path) {
|
||||
if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws);
|
||||
else addSub(docSubscribers, msg.path, ws);
|
||||
ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' }));
|
||||
} else if (msg.type === 'unsubscribe' && msg.path) {
|
||||
if (msg.kind === 'collection') removeSub(collSubscribers, msg.path, ws);
|
||||
else removeSub(docSubscribers, msg.path, ws);
|
||||
}
|
||||
});
|
||||
ws.on('close', () => dropWs(ws));
|
||||
ws.on('error', () => {});
|
||||
});
|
||||
|
||||
return {
|
||||
app, server, wss, store, db,
|
||||
close(done) {
|
||||
wss.clients.forEach(c => { try { c.terminate(); } catch {} });
|
||||
wss.close();
|
||||
server.close(() => { db.close(); if (done) done(); });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Boot standalone if run directly.
|
||||
if (require.main === module) {
|
||||
const port = parseInt(process.env.PORT, 10) || 4001;
|
||||
const dbPath = process.env.DB_PATH || './data/tracker.sqlite';
|
||||
const { server } = createServer({ dbPath, port });
|
||||
server.listen(port, () => {
|
||||
console.log(`ttrpg backend listening on :${port} (db: ${dbPath})`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createServer };
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/tests/**/*.test.js'],
|
||||
testTimeout: 10000,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@ttrpg/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted backend: Express + ws + better-sqlite3. Server-authoritative turn logic.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch index.js",
|
||||
"start": "node index.js",
|
||||
"test": "jest --forceExit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ttrpg/shared": "*",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"nanoid": "^5.0.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Layer 2 test: exercise ws.js storage adapter against a LIVE backend.
|
||||
// Complements Layer 1 (App + firebase mock) which proves App call shape but
|
||||
// never touches ws.js. This catches translation bugs in the adapter.
|
||||
//
|
||||
// Runs the shared storage contract (same spec memory/firebase satisfy) against
|
||||
// createWsStorage pointed at an ephemeral backend instance. A FRESH backend is
|
||||
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { createServer } = require('../index');
|
||||
const { createWsStorage } = require('../../src/storage/ws');
|
||||
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() {
|
||||
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
||||
const handle = createServer({ dbPath, port: 0 });
|
||||
await new Promise((resolve, reject) => {
|
||||
handle.server.on('error', reject);
|
||||
handle.server.listen(0, resolve);
|
||||
});
|
||||
const port = handle.server.address().port;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const wsUrl = `ws://127.0.0.1:${port}/ws`;
|
||||
const storage = createWsStorage({ baseUrl, wsUrl });
|
||||
storage.dispose = (done) => handle.close(done);
|
||||
return storage;
|
||||
}
|
||||
|
||||
runStorageContract('ws (live backend)', makeStorage);
|
||||
@@ -0,0 +1,56 @@
|
||||
// BUG-8: ws adapter has NO reconnect. WS dies (idle/error/close) → wsReady=null,
|
||||
// subscribers dead forever, no re-subscribe. Display frozen until full reload.
|
||||
// Test: subscribe, write (cb fires), force-drop WS, write again (must still fire).
|
||||
// RED on current.
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { createServer } = require('../index');
|
||||
const { createWsStorage } = require('../../src/storage/ws');
|
||||
|
||||
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function makeStorage() {
|
||||
const dbPath = path.join(os.tmpdir(), `ws-recon-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
||||
const handle = createServer({ dbPath, port: 0 });
|
||||
await new Promise((resolve, reject) => {
|
||||
handle.server.on('error', reject);
|
||||
handle.server.listen(0, resolve);
|
||||
});
|
||||
const port = handle.server.address().port;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const wsUrl = `ws://127.0.0.1:${port}/ws`;
|
||||
const storage = createWsStorage({ baseUrl, wsUrl });
|
||||
storage.dispose = (done) => handle.close(done);
|
||||
return storage;
|
||||
}
|
||||
|
||||
describe('BUG-8: ws adapter reconnect after drop', () => {
|
||||
test('subscribe fires cb after WS dropped + restored', async () => {
|
||||
const storage = await makeStorage();
|
||||
try {
|
||||
await storage.setDoc('campaigns/a', { name: 'V1' });
|
||||
const calls = [];
|
||||
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||
await flush();
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// force-drop WS (simulates idle timeout / network blip)
|
||||
storage._test.forceDrop();
|
||||
await flush(300);
|
||||
// wsReady should be null now
|
||||
expect(storage._test.getReady()).toBeNull();
|
||||
|
||||
// write again — subscriber must re-fire after reconnect
|
||||
await storage.setDoc('campaigns/a', { name: 'V2' });
|
||||
await flush(1000);
|
||||
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toEqual({ name: 'V2' });
|
||||
} finally {
|
||||
await new Promise(r => storage.dispose(r));
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// @ttrpg/shared — barrel export.
|
||||
module.exports = require('./turn.js');
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/tests/**/*.test.js'],
|
||||
collectCoverageFrom: ['turn.js'],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@ttrpg/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Pure logic shared by client + server + tests. No I/O.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// 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.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
sortParticipantsByInitiative,
|
||||
computeTurnOrderAfterRemoval,
|
||||
computeTurnOrderAfterAddition,
|
||||
startEncounter,
|
||||
nextTurn,
|
||||
togglePause,
|
||||
addParticipant,
|
||||
removeParticipant,
|
||||
toggleParticipantActive,
|
||||
applyHpChange,
|
||||
deathSave,
|
||||
toggleCondition,
|
||||
reorderParticipants,
|
||||
endEncounter,
|
||||
makeParticipant,
|
||||
} = shared;
|
||||
|
||||
// Helper: minimal encounter with given participants.
|
||||
function enc(participants = [], extra = {}) {
|
||||
return {
|
||||
name: 'Test Encounter',
|
||||
participants,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative, maxHp: 20, currentHp: 20,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe('sortParticipantsByInitiative', () => {
|
||||
test('higher initiative first', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||
expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('ties broken by original order', () => {
|
||||
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||
expect(sorted.map(x => x.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startEncounter', () => {
|
||||
test('throws if no participants', () => {
|
||||
expect(() => startEncounter(enc([]))).toThrow('participants');
|
||||
});
|
||||
|
||||
test('throws if no active participants', () => {
|
||||
const e = enc([p('a', 10, { isActive: false })]);
|
||||
expect(() => startEncounter(e)).toThrow('active');
|
||||
});
|
||||
|
||||
test('sets round 1, turn order sorted, current = highest init', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
test('inactive stays in turn order slot (1-list model)', () => {
|
||||
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
|
||||
const { patch } = startEncounter(enc(ps));
|
||||
// 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
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextTurn', () => {
|
||||
test('throws if not started', () => {
|
||||
expect(() => nextTurn(enc([p('a', 10)], { isStarted: false }))).toThrow();
|
||||
});
|
||||
|
||||
test('throws if paused', () => {
|
||||
expect(() => nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }))).toThrow();
|
||||
});
|
||||
|
||||
test('advances to next in order, no round bump', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
round: 1,
|
||||
currentTurnParticipantId: 'b',
|
||||
turnOrderIds: ['b', 'c', 'a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.currentTurnParticipantId).toBe('c');
|
||||
expect(patch.round).toBe(1);
|
||||
});
|
||||
|
||||
test('wraps round when last in order', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
round: 1,
|
||||
currentTurnParticipantId: 'a',
|
||||
turnOrderIds: ['b', 'c', 'a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
expect(patch.round).toBe(2);
|
||||
});
|
||||
|
||||
test('ends encounter if no active participants', () => {
|
||||
const ps = [p('a', 10, { isActive: false })];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
round: 1,
|
||||
currentTurnParticipantId: 'a',
|
||||
turnOrderIds: ['a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.isStarted).toBe(false);
|
||||
expect(patch.currentTurnParticipantId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePause', () => {
|
||||
test('pauses started encounter', () => {
|
||||
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
|
||||
const { patch } = togglePause(e);
|
||||
expect(patch.isPaused).toBe(true);
|
||||
});
|
||||
|
||||
test('resume preserves turn order (no re-sort)', () => {
|
||||
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
|
||||
// and caused skips. Order frozen at startEncounter, patched incrementally.
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeParticipant', () => {
|
||||
test('removes from participants array', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = removeParticipant(enc(ps), 'a');
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('not started: no turn order mutation', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = removeParticipant(enc(ps), 'a');
|
||||
expect(patch.turnOrderIds).toBeUndefined();
|
||||
});
|
||||
|
||||
test('started: removes from turnOrderIds', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
test('started: removing current picks next active', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleParticipantActive', () => {
|
||||
test('deactivates participant', () => {
|
||||
const ps = [p('a', 10, { isActive: true })];
|
||||
const { patch } = toggleParticipantActive(enc(ps), 'a');
|
||||
expect(patch.participants[0].isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('started: deactivating current advances turn', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
test('started: reactivating inserts by initiative', () => {
|
||||
// BUG-5 fix: reactivated participant slots by initiative (not appended
|
||||
// to end). Preserves correct rotation order.
|
||||
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');
|
||||
// a init=10 > b init=5 → a slots before b
|
||||
expect(patch.turnOrderIds).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHpChange', () => {
|
||||
test('damage reduces hp, clamps 0', () => {
|
||||
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 5);
|
||||
expect(patch.participants[0].currentHp).toBe(10);
|
||||
});
|
||||
|
||||
test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => {
|
||||
// FEAT-1: death no longer deactivates or removes from turn order.
|
||||
// Dead stay in rotation, nextTurn still visits them, PCs get death-save turn.
|
||||
const ps = [p('a', 10, { 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(true);
|
||||
expect(patch.turnOrderIds).toBeUndefined();
|
||||
expect(patch.currentTurnParticipantId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('heal above 0 resets death saves, keeps active (FEAT-1)', () => {
|
||||
// FEAT-1: revive no longer flips isActive (was already active — death
|
||||
// doesn't deactivate). deathSaves still reset.
|
||||
const ps = [p('a', 10, { currentHp: 0, 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 clamps to maxHp', () => {
|
||||
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 10);
|
||||
expect(patch.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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deathSave', () => {
|
||||
test('increments saves', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathSaves: 0 })];
|
||||
const { patch } = deathSave(enc(ps), 'a', 1);
|
||||
expect(patch.participants[0].deathSaves).toBe(1);
|
||||
});
|
||||
|
||||
test('clicking same save decrements (toggle)', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
||||
const { patch } = deathSave(enc(ps), 'a', 2);
|
||||
expect(patch.participants[0].deathSaves).toBe(1);
|
||||
});
|
||||
|
||||
test('third save sets isDying', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
||||
const result = deathSave(enc(ps), 'a', 3);
|
||||
expect(result.patch.participants[0].deathSaves).toBe(3);
|
||||
expect(result.patch.participants[0].isDying).toBe(true);
|
||||
expect(result.isDying).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleCondition', () => {
|
||||
test('adds condition', () => {
|
||||
const ps = [p('a', 10, { conditions: [] })];
|
||||
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||
expect(patch.participants[0].conditions).toEqual(['poisoned']);
|
||||
});
|
||||
|
||||
test('removes condition', () => {
|
||||
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
|
||||
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||
expect(patch.participants[0].conditions).toEqual(['blinded']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderParticipants', () => {
|
||||
test('drag before target (1-list, cross-init allowed)', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
test('cross-init drag allowed (1-list, DM override)', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = reorderParticipants(enc(ps), 'a', 'b');
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endEncounter', () => {
|
||||
test('resets all combat state', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTurnOrderAfterRemoval', () => {
|
||||
test('not started = empty', () => {
|
||||
const out = computeTurnOrderAfterRemoval(enc([]), 'a', []);
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
test('removing non-current: no turnOrderIds patch (1-list syncs at call site)', () => {
|
||||
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||
const out = computeTurnOrderAfterRemoval(e, 'a', []);
|
||||
// 1-list: removal syncs turnOrderIds via participants[] at call site.
|
||||
// Helper only handles current-advance. Non-current = empty patch.
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTurnOrderAfterAddition', () => {
|
||||
test('not started = empty', () => {
|
||||
const out = computeTurnOrderAfterAddition(enc([]), 'a');
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
test('returns insertAt (1-list: caller splices + syncs)', () => {
|
||||
const e = enc([p('a',3)], { isStarted: true, turnOrderIds: ['a'], participants: [p('a',3)] });
|
||||
const out = computeTurnOrderAfterAddition(e, 'a');
|
||||
// already present → no-op
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
test('no-op if already present', () => {
|
||||
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'] });
|
||||
const out = computeTurnOrderAfterAddition(e, 'a');
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addParticipant', () => {
|
||||
test('appends participant', () => {
|
||||
const np = p('z', 7);
|
||||
const { patch } = addParticipant(enc([p('a', 10)]), np);
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
test('rejects duplicate id (skip-bug root cause)', () => {
|
||||
// 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 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// Combat integrity test: replay exact op sequence through pure 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.
|
||||
//
|
||||
// Mirrors scripts/replay-combat.js op order:
|
||||
// damage, heal (cleric), conditions, toggleActive, deathSave,
|
||||
// removeParticipant, addParticipant, updateParticipant, pause/resume,
|
||||
// reorderParticipants, revive-between-rounds.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
// ---- seeded RNG (deterministic, reproducible) ----
|
||||
let _seed = 12345;
|
||||
function rand() {
|
||||
// LCG
|
||||
_seed = (_seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return _seed / 0x7fffffff;
|
||||
}
|
||||
const rnd = (n) => Math.floor(rand() * n);
|
||||
const pick = (arr) => arr[rnd(arr.length)];
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 200, currentHp: 200,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function setupEncounter() {
|
||||
const ps = [
|
||||
buildCharacterParticipant({ id:'c1', name:'Fighter', defaultMaxHp:200, defaultInitMod:2 }).participant,
|
||||
buildCharacterParticipant({ id:'c2', name:'Cleric', defaultMaxHp:180, defaultInitMod:1 }).participant,
|
||||
buildCharacterParticipant({ id:'c3', name:'Rogue', defaultMaxHp:160, defaultInitMod:3 }).participant,
|
||||
buildMonsterParticipant({ name:'Goblin1', maxHp:100, initMod:2 }).participant,
|
||||
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,
|
||||
];
|
||||
// give deterministic ids to monsters for assertions
|
||||
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' };
|
||||
ps.forEach((part) => { if (idMap[part.name]) part.id = idMap[part.name]; });
|
||||
return {
|
||||
name: 'combat-test', participants: ps,
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function currentParticipant(e) {
|
||||
if (!e.currentTurnParticipantId) return null;
|
||||
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', () => {
|
||||
_seed = 12345; // reset for reproducibility
|
||||
let e = setupEncounter();
|
||||
e = apply(e, startEncounter(e));
|
||||
|
||||
let totalTurns = 0;
|
||||
let lastPaused = false;
|
||||
let lastReorder = 0;
|
||||
let reinforcementsAdded = 0;
|
||||
const condQueue = [...CONDITIONS];
|
||||
|
||||
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
const startRound = e.round;
|
||||
const seenThisRound = [];
|
||||
const cap = (e.participants.length + 2) * 2;
|
||||
let guard = 0;
|
||||
|
||||
while (e.round === startRound && guard < cap) {
|
||||
// resume if paused (must precede nextTurn)
|
||||
if (lastPaused) { e = apply(e, togglePause(e)); lastPaused = false; }
|
||||
|
||||
// advance
|
||||
let t;
|
||||
try { t = nextTurn(e); } 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);
|
||||
|
||||
const actor = currentParticipant(e);
|
||||
|
||||
// 1. damage
|
||||
if (actor) {
|
||||
const foes = e.participants.filter(
|
||||
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false
|
||||
);
|
||||
if (foes.length > 0) {
|
||||
const tgt = pick(foes);
|
||||
const dmg = 1 + rnd(5);
|
||||
e = apply(e, applyHpChange(e, tgt.id, 'damage', dmg));
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
.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));
|
||||
}
|
||||
}
|
||||
// 3. conditions
|
||||
if (condQueue.length > 0) {
|
||||
const cond = condQueue[0];
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, toggleCondition(e, tgt.id, cond)); condQueue.shift(); }
|
||||
catch (err) { condQueue.shift(); }
|
||||
}
|
||||
} else if (totalTurns % 6 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const cond = pick(CONDITIONS);
|
||||
try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 4. toggleParticipantActive
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, toggleParticipantActive(e, tgt.id)); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 5. deathSave
|
||||
if (actor && actor.currentHp <= 0 && !actor.isNpc) {
|
||||
try { e = apply(e, deathSave(e, actor.id, 1)); } catch (err) {}
|
||||
}
|
||||
// 6. removeParticipant
|
||||
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) {} }
|
||||
}
|
||||
// 7. addParticipant
|
||||
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 },
|
||||
]);
|
||||
const built = buildMonsterParticipant(spec).participant;
|
||||
try { e = apply(e, addParticipant(e, built)); reinforcementsAdded++; } catch (err) {}
|
||||
}
|
||||
// 8. updateParticipant
|
||||
if (totalTurns % 7 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try { e = apply(e, updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` })); } catch (err) {}
|
||||
}
|
||||
}
|
||||
// 9. pause
|
||||
if (totalTurns % 12 === 0 && !lastPaused) { e = apply(e, togglePause(e)); 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);
|
||||
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));
|
||||
lastReorder = totalTurns;
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
guard++;
|
||||
if (!e.isStarted) break;
|
||||
}
|
||||
if (!e.isStarted) break;
|
||||
|
||||
// === per-round invariants ===
|
||||
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) });
|
||||
}
|
||||
// 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
|
||||
if (e.currentTurnParticipantId) {
|
||||
const ct = e.participants.find(p => p.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) {
|
||||
if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) {
|
||||
violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp });
|
||||
}
|
||||
}
|
||||
|
||||
// revive dead between rounds
|
||||
const dead = e.participants.filter(p => p.currentHp <= 0 || p.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));
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Report
|
||||
if (violations.length > 0) {
|
||||
const byType = {};
|
||||
violations.forEach(v => { byType[v.type] = (byType[v.type]||0) + 1; });
|
||||
const summary = Object.entries(byType).sort((a,b)=>b[1]-a[1]).map(([k,n])=>`${n}x ${k}`).join(', ');
|
||||
const first5 = violations.slice(0,5).map(v => `r${v.round} ${v.type}${v.seen?': '+JSON.stringify(v.seen):''}${v.order?': '+JSON.stringify(v.order):''}${v.msg?': '+v.msg:''}`).join('\n ');
|
||||
// dump full state for first dupe for triage
|
||||
throw new Error(`combat integrity violations: ${violations.length}\n ${summary}\n first 5:\n ${first5}`);
|
||||
}
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// M4 desired behavior: dead PC stays in turn order, turn still comes up,
|
||||
// deathSave fires. Current code filters isActive (set false on death) so
|
||||
// dead participants are SKIPPED. Test asserts desired state = RED on current.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
||||
|
||||
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,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
|
||||
describe('M4: dead participants stay in turn order', () => {
|
||||
test('dead PC not removed from turnOrderIds', () => {
|
||||
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const orderBefore = e.turnOrderIds.slice();
|
||||
// kill b
|
||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
test('dead PC turn still comes up (nextTurn visits them)', () => {
|
||||
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→b→c. b's turn should come up.
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
|
||||
test('dead PC on their turn can deathSave', () => {
|
||||
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 };
|
||||
// advance to b's turn
|
||||
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);
|
||||
expect(r.patch).toBeTruthy();
|
||||
const b = r.patch.participants.find(x => x.id === 'b');
|
||||
expect(b.deathSaves).toBe(1);
|
||||
});
|
||||
|
||||
test('dead PC not 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(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// 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.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, toggleParticipantActive } = shared;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
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);
|
||||
expect(deact.currentTurnParticipantId).toBe('a');
|
||||
expect(deact.round).toBe(nt.round);
|
||||
expect(deact.round).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// INVARIANT test: ONE list. turnOrderIds === participants.map(id) always.
|
||||
// No re-sort after startEncounter. nextTurn follows list order, skipping inactive.
|
||||
// Drag (reorder) overrides initiative — cross-init drag allowed + reflected.
|
||||
// Display === rotation by construction (same array).
|
||||
//
|
||||
// RED now: current code has two lists (sort on display, frozen turnOrderIds),
|
||||
// reorder throws on cross-init. Refactor (1-list model) greens these.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant,
|
||||
startEncounter, nextTurn, addParticipant, removeParticipant,
|
||||
toggleParticipantActive, togglePause, applyHpChange,
|
||||
reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
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;
|
||||
|
||||
// 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', () => {
|
||||
let e = enc([p('a',3),p('b',10),p('c',5)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
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)', () => {
|
||||
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
// 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', () => {
|
||||
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
|
||||
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', () => {
|
||||
let e = enc([p('a',10)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, addParticipant(e, p('b',5)));
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
test('removeParticipant: list synced, order preserved', () => {
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e));
|
||||
e = apply(e, removeParticipant(e, 'b'));
|
||||
expect(e.turnOrderIds).toEqual(['a','c']);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
test('reorder cross-init: allowed, list + rotation reflect new order', () => {
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // [a,b,c]
|
||||
e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a
|
||||
expect(e.turnOrderIds).toEqual(['c','a','b']);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
|
||||
test('reorder: rotation follows new list order', () => {
|
||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||
e = apply(e, startEncounter(e)); // [a,b,c], cur=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
|
||||
expect(rot).toEqual(['a','c','b']);
|
||||
});
|
||||
|
||||
test('toggle inactive: list unchanged (stays in rotation slot)', () => {
|
||||
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
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
|
||||
});
|
||||
|
||||
test('toggle inactive: nextTurn skips b, visits a→c', () => {
|
||||
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
|
||||
expect(e.currentTurnParticipantId).toBe('c');
|
||||
});
|
||||
|
||||
test('hp death + revive: list unchanged', () => {
|
||||
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
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||
e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive
|
||||
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Characterization test: addParticipant + pause/resume corrupts turn rotation.
|
||||
// Audit found 56-77 violations/100 rounds starting round 20 in pure turn.js
|
||||
// simulation. Visible in live replay (round 10: 17 turns, 6 duped actors,
|
||||
// R-series stuck repeating forever).
|
||||
//
|
||||
// This test uses FRESH ids (crypto.randomUUID equivalent) — NOT the audit's
|
||||
// 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).
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative, maxHp: 100, currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function enc(ps) {
|
||||
return {
|
||||
name: 'T', participants: ps,
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('addParticipant + pause/resume rotation corruption', () => {
|
||||
test('add fresh participant while paused, resume, rotation completes full cycle', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
e = { ...e, ...togglePause(e).patch }; // 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)
|
||||
|
||||
// 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 };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
// EXPECT: 4 unique (a,b,c,x). BUG: rotation may not visit all.
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
|
||||
test('multiple adds while paused, resume, rotation visits all', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
e = { ...e, ...togglePause(e).patch }; // 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 = { ...e, ...togglePause(e).patch }; // resume
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
// EXPECT: all 6 participants reachable. BUG: some stuck/repeated.
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
|
||||
test('add while running, then pause+resume, rotation stays valid', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // 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
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// removeParticipant + computeTurnOrderAfterRemoval edge cases.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||
}
|
||||
function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
|
||||
describe('removeParticipant turn-order edges', () => {
|
||||
test('removing current picks next active as current', () => {
|
||||
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
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
|
||||
test('removing last in order wraps current to first', () => {
|
||||
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 };
|
||||
expect(e.currentTurnParticipantId).toBe('a');
|
||||
});
|
||||
|
||||
test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', () => {
|
||||
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||
e = { ...e, ...startEncounter(e).patch }; // [a,b,c], cur=a
|
||||
// deactivate b + c (stay in slot, inactive)
|
||||
e = { ...e, ...toggleParticipantActive(e, 'b').patch };
|
||||
e = { ...e, ...toggleParticipantActive(e, 'c').patch };
|
||||
// remove current a
|
||||
e = { ...e, ...removeParticipant(e, 'a').patch };
|
||||
// 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', () => {
|
||||
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
e = { ...e, ...removeParticipant(e, 'b').patch };
|
||||
expect(e.currentTurnParticipantId).toBe('a');
|
||||
expect(e.turnOrderIds).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
test('removing current that is dead (HP=0) - BUG-3 overlap', () => {
|
||||
// Dead participant removed mid-combat. Desired (M4): they STAY in order.
|
||||
// removeParticipant is explicit DM action, distinct from auto-skip.
|
||||
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 };
|
||||
expect(e.turnOrderIds).not.toContain('b');
|
||||
expect(e.participants.find(x => x.id === 'b')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 100, currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
function enc(ps) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
|
||||
describe('reorderParticipants', () => {
|
||||
test('drag before target (1-list model)', () => {
|
||||
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
// initial order: b,c,a (init 20,20,10)
|
||||
expect(e.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
// drag c before b: remove c → [b,a], insert before b → [c,b,a]
|
||||
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
test('cross-init drag allowed (1-list, DM override)', () => {
|
||||
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.participants.map(p => p.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('throws if id not found', () => {
|
||||
const ps = [p('a', 10), p('b', 20)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
expect(() => reorderParticipants(e, 'a', 'zzz')).toThrow();
|
||||
});
|
||||
|
||||
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => {
|
||||
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const r = reorderParticipants(e, 'c', 'b');
|
||||
expect(r.patch.turnOrderIds).toEqual(['c', 'b', 'a']);
|
||||
expect(r.patch.turnOrderIds).toEqual(r.patch.participants.map(p => p.id));
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
// order: b,c,a
|
||||
e = { ...e, ...reorderParticipants(e, 'c', 'b').patch };
|
||||
expect(e.turnOrderIds).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// Regression test: full round must rotate through ALL active participants exactly once.
|
||||
// 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.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { startEncounter, nextTurn, makeParticipant } = shared;
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative, maxHp: 20, currentHp: 20,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function enc(ps) {
|
||||
return {
|
||||
name: 'T', participants: ps,
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('round rotation integrity', () => {
|
||||
test('3 participants: one full round visits each exactly once', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
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 };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
|
||||
expect(e.round).toBe(1); // still round 1
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(startOrder.length); // each exactly once
|
||||
expect(visited.length).toBe(startOrder.length);
|
||||
});
|
||||
|
||||
test('8 participants (replay shape): one full round visits each exactly once', () => {
|
||||
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 };
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
|
||||
expect(e.round).toBe(1);
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(startOrder.length);
|
||||
expect(visited.length).toBe(startOrder.length);
|
||||
});
|
||||
|
||||
test('multiple rounds: each round visits each participant exactly once', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const expectedRound = e.round;
|
||||
|
||||
// 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 };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(startOrder.length);
|
||||
expect(e.round).toBe(expectedRound);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
e = { ...e, ...nextTurn(e).patch }; 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);
|
||||
// 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', () => {
|
||||
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 };
|
||||
// 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 };
|
||||
|
||||
// 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 };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
expect(visited).toContain('c');
|
||||
});
|
||||
|
||||
test('addParticipant mid-round: new participant gets turn this round or next', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // advance one
|
||||
// add new participant
|
||||
const newP = p('x', 25);
|
||||
e = { ...e, ...addParticipant(e, newP).patch };
|
||||
|
||||
// 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 };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
if (visited.length > 20) break; // safety
|
||||
}
|
||||
const originals = visited.filter(id => ['a','b','c'].includes(id));
|
||||
const uniq = new Set(originals);
|
||||
expect(uniq.size).toBe(3);
|
||||
});
|
||||
|
||||
test('reorderParticipants mid-round keeps rotation valid', () => {
|
||||
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 };
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
// reorder: swap b,c (same initiative)
|
||||
e = { ...e, ...reorderParticipants(e, 'b', 'c').patch };
|
||||
|
||||
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Invariant: no real skip. Every active participant at round start (still
|
||||
// active at round end) gets a turn. Tracks per ACTUAL round (e.round), so
|
||||
// rounds spanning pause/resume across loop iterations count correctly.
|
||||
//
|
||||
// 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.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause, addParticipant, removeParticipant,
|
||||
toggleParticipantActive,
|
||||
} = shared;
|
||||
|
||||
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() {
|
||||
const ps = [
|
||||
buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant,
|
||||
buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant,
|
||||
buildCharacterParticipant({ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }).participant,
|
||||
buildMonsterParticipant({ name: 'Goblin1', maxHp: 100, initMod: 2 }).participant,
|
||||
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,
|
||||
];
|
||||
let e = {
|
||||
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
return apply(e, startEncounter(e));
|
||||
}
|
||||
|
||||
describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||
jest.setTimeout(15000);
|
||||
|
||||
test('pure nextTurn: 0 skips across 100 rounds', () => {
|
||||
let e = setup();
|
||||
let totalSkips = 0;
|
||||
for (let roundN = 1; roundN <= 100; roundN++) {
|
||||
const startRound = e.round;
|
||||
const activeAtStart = new Set(e.participants.filter(p => p.isActive).map(p => p.id));
|
||||
const acted = new Set();
|
||||
acted.add(e.currentTurnParticipantId);
|
||||
let guard = 0;
|
||||
const cap = e.participants.length + 1;
|
||||
while (e.round === startRound && guard < cap) {
|
||||
e = apply(e, nextTurn(e));
|
||||
if (e.round === startRound) acted.add(e.currentTurnParticipantId);
|
||||
guard++;
|
||||
}
|
||||
const skipped = [...activeAtStart].filter(id => {
|
||||
const p = e.participants.find(x => x.id === id);
|
||||
return p && p.isActive && !acted.has(id);
|
||||
});
|
||||
totalSkips += skipped.length;
|
||||
}
|
||||
expect(totalSkips).toBe(0);
|
||||
});
|
||||
|
||||
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', () => {
|
||||
let e = setup();
|
||||
const N = nm(e);
|
||||
let curRound = null;
|
||||
let activeAtRoundStart = new Set();
|
||||
let actedThisRound = new Set();
|
||||
const onRoundStart = (enc) => {
|
||||
curRound = enc.round;
|
||||
activeAtRoundStart = new Set(enc.participants.filter(p => p.isActive).map(p => p.id));
|
||||
actedThisRound = new Set();
|
||||
if (enc.currentTurnParticipantId) actedThisRound.add(enc.currentTurnParticipantId);
|
||||
};
|
||||
onRoundStart(e);
|
||||
|
||||
let totalRealSkips = 0;
|
||||
let added = 0;
|
||||
let turns = 0;
|
||||
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; }
|
||||
const prevRound = e.round;
|
||||
e = apply(e, nextTurn(e));
|
||||
if (e.round !== prevRound) {
|
||||
const skipped = [...activeAtRoundStart].filter(id => {
|
||||
const p = e.participants.find(x => x.id === id);
|
||||
return p && p.isActive && !actedThisRound.has(id);
|
||||
});
|
||||
totalRealSkips += skipped.length;
|
||||
onRoundStart(e);
|
||||
} else {
|
||||
actedThisRound.add(e.currentTurnParticipantId);
|
||||
}
|
||||
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++;
|
||||
}
|
||||
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 (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));
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(totalRealSkips).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// Undo roundtrip: every op that returns log.undo must restore prior state.
|
||||
// Apply op → patch → apply undo → assert deepEqual original.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||
addParticipant, removeParticipant, toggleParticipantActive,
|
||||
applyHpChange, toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 100, currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
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));
|
||||
|
||||
describe('undo roundtrip', () => {
|
||||
test('startEncounter undo restores pre-start', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test('nextTurn undo restores prior currentTurn/round', () => {
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('togglePause undo restores prior paused state', () => {
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('applyHpChange undo restores prior participants', () => {
|
||||
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('toggleCondition undo restores prior participants', () => {
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('toggleParticipantActive undo restores prior participants + turn order', () => {
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('addParticipant undo restores prior participants', () => {
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('removeParticipant undo restores prior participants + turn order', () => {
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('endEncounter undo restores prior state', () => {
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
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);
|
||||
});
|
||||
|
||||
test('reorderParticipants has no undo (log: null) — BUG candidate', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
// @ttrpg/shared — turn.js
|
||||
// Pure turn-order logic. No I/O, no React, no Firebase.
|
||||
// Ported VERBATIM from src/App.js (M1). Bugs preserved intentionally.
|
||||
// Characterization tests lock current behavior. Fixes come in M4.
|
||||
//
|
||||
// Functions return NEW state (immutable). They never mutate input encounter.
|
||||
|
||||
'use strict';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Constants (mirror src/App.js)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_MAX_HP = 10;
|
||||
const DEFAULT_INIT_MOD = 0;
|
||||
const MONSTER_DEFAULT_INIT_MOD = 2;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Utility functions (verbatim from src/App.js)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const generateId = () =>
|
||||
(typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
||||
|
||||
const formatInitMod = (mod) => {
|
||||
if (mod === undefined || mod === null) return 'N/A';
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
};
|
||||
|
||||
// Sort used ONLY at insert points (startEncounter, addParticipant) to position
|
||||
// participants by initiative. Once positioned, turnOrderIds = participants.map(id)
|
||||
// (1-list model). No re-sort after start — drag/edit are manual overrides.
|
||||
const sortParticipantsByInitiative = (participants, originalOrder) => {
|
||||
return [...participants].sort((a, b) => {
|
||||
if (a.initiative === b.initiative) {
|
||||
const indexA = originalOrder.findIndex(p => p.id === a.id);
|
||||
const indexB = originalOrder.findIndex(p => p.id === b.id);
|
||||
return indexA - indexB;
|
||||
}
|
||||
return b.initiative - a.initiative;
|
||||
});
|
||||
};
|
||||
|
||||
// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id).
|
||||
// Call after any participants[] mutation. Returns turnOrderIds patch.
|
||||
const syncTurnOrder = (participants) => ({
|
||||
turnOrderIds: participants.map(p => p.id),
|
||||
});
|
||||
|
||||
// SHARED ADVANCE CORE (BUG-5 DRY fix).
|
||||
// Single source of truth for "who acts next". Both nextTurn and
|
||||
// computeTurnOrderAfterRemoval delegate here — prevents drift where one path
|
||||
// changes and the other doesn't.
|
||||
//
|
||||
// order: turnOrderIds (raw, may contain inactive/removed ids).
|
||||
// fromPos: index of the last-acted slot (current participant, or the removed
|
||||
// participant's old slot). Step +1 forward, skip fromPos itself.
|
||||
// isActive: predicate id -> bool.
|
||||
// Returns { nextId, wrapped }. wrapped = cycled past order end = new round.
|
||||
const nextActiveAfter = (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 }; // no other active participant
|
||||
};
|
||||
|
||||
// Verbatim from src/App.js. Returns turnOrderIds/currentTurnParticipantId updates
|
||||
// when a participant leaves active combat.
|
||||
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
||||
if (!encounter.isStarted) return {};
|
||||
// 1-list: turnOrderIds syncs from participants[].map(id) at call site.
|
||||
// Here only handle current-advance if removed == current.
|
||||
const updates = {};
|
||||
if (encounter.currentTurnParticipantId === removedId) {
|
||||
const removedPos = (encounter.turnOrderIds || []).indexOf(removedId);
|
||||
const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive);
|
||||
const { nextId, wrapped } = nextActiveAfter(encounter.turnOrderIds || [], removedPos, isActive);
|
||||
updates.currentTurnParticipantId = nextId;
|
||||
if (nextId && wrapped) updates.round = (encounter.round || 1) + 1;
|
||||
}
|
||||
return updates;
|
||||
};
|
||||
|
||||
// Insert addedId into turnOrderIds by initiative. New participant slots into
|
||||
// correct initiative position at add time (not appended to end). Preserves
|
||||
// current pointer — no re-sort anywhere except startEncounter.
|
||||
// Tie rule: insert AFTER existing same-init (preserves creation order).
|
||||
// NOTE: 1-list model — caller syncs participants[] in same pos as insert target.
|
||||
const computeTurnOrderAfterAddition = (encounter, addedId) => {
|
||||
if (!encounter.isStarted) return {};
|
||||
const currentIds = encounter.turnOrderIds || [];
|
||||
if (currentIds.includes(addedId)) return {};
|
||||
const added = (encounter.participants || []).find(p => p.id === addedId);
|
||||
if (!added) return {};
|
||||
// find first id with strictly lower initiative; insert before it (== after all >= )
|
||||
const initOf = id => {
|
||||
const p = (encounter.participants || []).find(x => x.id === id);
|
||||
return p ? (p.initiative || 0) : 0;
|
||||
};
|
||||
const addedInit = added.initiative || 0;
|
||||
let insertAt = currentIds.length;
|
||||
for (let i = 0; i < currentIds.length; i++) {
|
||||
if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; }
|
||||
}
|
||||
return { insertAt }; // caller splices participants[] at this pos, then syncs
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Participant factory (mirrors ParticipantManager.handleAddParticipant shape)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function makeParticipant(opts) {
|
||||
return {
|
||||
id: opts.id || generateId(),
|
||||
name: opts.name,
|
||||
type: opts.type, // 'character' | 'monster'
|
||||
originalCharacterId: opts.originalCharacterId || null,
|
||||
initiative: opts.initiative,
|
||||
maxHp: opts.maxHp,
|
||||
currentHp: opts.currentHp,
|
||||
isNpc: opts.isNpc || false,
|
||||
conditions: opts.conditions || [],
|
||||
isActive: opts.isActive !== undefined ? opts.isActive : true,
|
||||
deathSaves: opts.deathSaves || 0,
|
||||
isDying: opts.isDying || false,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a character participant from a campaign character (rolls initiative).
|
||||
function buildCharacterParticipant(character) {
|
||||
const initiativeRoll = rollD20();
|
||||
const modifier = character.defaultInitMod || 0;
|
||||
const finalInitiative = initiativeRoll + modifier;
|
||||
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
||||
return {
|
||||
participant: makeParticipant({
|
||||
name: character.name,
|
||||
type: 'character',
|
||||
originalCharacterId: character.id,
|
||||
initiative: finalInitiative,
|
||||
maxHp,
|
||||
currentHp: maxHp,
|
||||
isNpc: false,
|
||||
}),
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||
};
|
||||
}
|
||||
|
||||
// Build a monster participant (rolls initiative).
|
||||
function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) {
|
||||
const initiativeRoll = rollD20();
|
||||
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
||||
const finalInitiative = initiativeRoll + modifier;
|
||||
const hp = maxHp || DEFAULT_MAX_HP;
|
||||
return {
|
||||
participant: makeParticipant({
|
||||
name,
|
||||
type: 'monster',
|
||||
initiative: finalInitiative,
|
||||
maxHp: hp,
|
||||
currentHp: hp,
|
||||
isNpc: isNpc || false,
|
||||
}),
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Action handlers — pure: (encounter, action) => { encounter, patch, log }
|
||||
// Return patch = partial fields to merge into stored encounter.
|
||||
// Caller persists patch + broadcasts.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// START_ENCOUNTER — verbatim from InitiativeControls.handleStartEncounter
|
||||
function startEncounter(encounter) {
|
||||
if (!encounter.participants || encounter.participants.length === 0) {
|
||||
throw new Error('Add participants first.');
|
||||
}
|
||||
// 1-list model: sort ALL participants by init (active + inactive) so display
|
||||
// order = initiative. nextTurn skips inactive. turnOrderIds mirrors list.
|
||||
const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants);
|
||||
const firstActive = sortedParticipants.find(p => p.isActive);
|
||||
if (!firstActive) {
|
||||
throw new Error('No active participants.');
|
||||
}
|
||||
const orderedParticipants = sortedParticipants;
|
||||
return {
|
||||
patch: {
|
||||
isStarted: true,
|
||||
isPaused: false,
|
||||
round: 1,
|
||||
participants: orderedParticipants,
|
||||
currentTurnParticipantId: firstActive.id,
|
||||
turnOrderIds: orderedParticipants.map(p => p.id),
|
||||
},
|
||||
log: {
|
||||
message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`,
|
||||
undo: {
|
||||
isStarted: encounter.isStarted ?? false,
|
||||
isPaused: encounter.isPaused ?? false,
|
||||
round: encounter.round ?? 0,
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// NEXT_TURN — verbatim from InitiativeControls.handleNextTurn
|
||||
// NOTE: this is the suspected skip-bug source. Preserved for M3 characterization.
|
||||
function nextTurn(encounter) {
|
||||
if (!encounter.isStarted || encounter.isPaused) {
|
||||
throw new Error('Encounter not running.');
|
||||
}
|
||||
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) {
|
||||
throw new Error('No active turn.');
|
||||
}
|
||||
|
||||
const activePsInOrder = encounter.turnOrderIds
|
||||
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
|
||||
.filter(Boolean);
|
||||
|
||||
if (activePsInOrder.length === 0) {
|
||||
// End encounter — no active participants left.
|
||||
return {
|
||||
patch: {
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
currentTurnParticipantId: null,
|
||||
round: encounter.round,
|
||||
},
|
||||
log: { message: `Combat auto-ended: no active participants`, undo: null },
|
||||
};
|
||||
}
|
||||
|
||||
let nextRound = encounter.round;
|
||||
let newTurnOrderIds = encounter.turnOrderIds;
|
||||
|
||||
// Delegate to shared advance core (BUG-5 DRY fix). Same math
|
||||
// computeTurnOrderAfterRemoval uses → no drift. fromPos = current's slot
|
||||
// in raw turnOrderIds; -1 path handles removed/stale current.
|
||||
const order = encounter.turnOrderIds || [];
|
||||
const fromPos = order.indexOf(encounter.currentTurnParticipantId);
|
||||
const isActive = id => {
|
||||
const p = encounter.participants.find(x => x.id === id);
|
||||
return !!p && p.isActive;
|
||||
};
|
||||
const { nextId, wrapped } = nextActiveAfter(order, fromPos, isActive);
|
||||
|
||||
if (!nextId) {
|
||||
throw new Error('Could not determine next participant.');
|
||||
}
|
||||
if (wrapped) nextRound += 1;
|
||||
|
||||
const nextParticipant = encounter.participants.find(p => p.id === nextId);
|
||||
|
||||
return {
|
||||
patch: {
|
||||
currentTurnParticipantId: nextParticipant.id,
|
||||
round: nextRound,
|
||||
turnOrderIds: newTurnOrderIds,
|
||||
},
|
||||
log: {
|
||||
message: `${nextParticipant.name}'s turn (Round ${nextRound})`,
|
||||
undo: {
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
round: encounter.round,
|
||||
turnOrderIds: [...encounter.turnOrderIds],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// PAUSE / RESUME — verbatim from InitiativeControls.handleTogglePause
|
||||
function togglePause(encounter) {
|
||||
if (!encounter || !encounter.isStarted) {
|
||||
throw new Error('Encounter not started.');
|
||||
}
|
||||
const newPausedState = !encounter.isPaused;
|
||||
let newTurnOrderIds = encounter.turnOrderIds;
|
||||
if (!newPausedState && encounter.isPaused) {
|
||||
// Resume: do NOT re-sort. Re-sorting displaces the current pointer —
|
||||
// participants who already acted move earlier in order and nextTurn
|
||||
// revisits them (whole round replays). Order is frozen at startEncounter
|
||||
// and patched incrementally; resume keeps it stable.
|
||||
}
|
||||
return {
|
||||
patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds },
|
||||
log: {
|
||||
message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`,
|
||||
undo: {
|
||||
isPaused: encounter.isPaused ?? false,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
|
||||
// If encounter already started, also slot participant into turnOrderIds by
|
||||
// initiative (via computeTurnOrderAfterAddition).
|
||||
function addParticipant(encounter, participant) {
|
||||
if ((encounter.participants || []).some(p => p.id === participant.id)) {
|
||||
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
|
||||
}
|
||||
// 1-list: splice participant into participants[] by initiative position,
|
||||
// then sync turnOrderIds = participants.map(id).
|
||||
let updatedParticipants;
|
||||
let insertAt;
|
||||
if (!encounter.isStarted) {
|
||||
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 {
|
||||
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||
log: {
|
||||
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
||||
undo: {
|
||||
participants: [...(encounter.participants || [])],
|
||||
...(encounter.isStarted ? {
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
} : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
|
||||
function addParticipants(encounter, newParticipants) {
|
||||
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
||||
return { patch: { participants: updatedParticipants }, log: null };
|
||||
}
|
||||
|
||||
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
|
||||
function updateParticipant(encounter, participantId, updatedData) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...updatedData } : p
|
||||
);
|
||||
return { patch: { participants: updatedParticipants }, log: null };
|
||||
}
|
||||
|
||||
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
||||
function removeParticipant(encounter, participantId) {
|
||||
const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId);
|
||||
const advUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
||||
const turnUpdates = encounter.isStarted ? { ...syncTurnOrder(updatedParticipants), ...advUpdates } : {};
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
return {
|
||||
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||
log: {
|
||||
message: `${participant ? participant.name : 'Participant'} removed from encounter`,
|
||||
undo: {
|
||||
participants: [...(encounter.participants || [])],
|
||||
...(encounter.isStarted ? {
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
} : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// TOGGLE_ACTIVE — verbatim from ParticipantManager.toggleParticipantActive
|
||||
function toggleParticipantActive(encounter, participantId) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const newIsActive = !participant.isActive;
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, isActive: newIsActive } : p
|
||||
);
|
||||
// 1-list: participant stays in slot on toggle (active or not). nextTurn
|
||||
// skips inactive. Only advance current if deact hits current.
|
||||
let turnUpdates = {};
|
||||
if (encounter.isStarted) {
|
||||
turnUpdates = syncTurnOrder(updatedParticipants);
|
||||
if (!newIsActive && encounter.currentTurnParticipantId === participantId) {
|
||||
const adv = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
||||
turnUpdates = { ...turnUpdates, ...adv };
|
||||
}
|
||||
}
|
||||
return {
|
||||
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||
log: {
|
||||
message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`,
|
||||
undo: {
|
||||
participants: [...(encounter.participants || [])],
|
||||
...(encounter.isStarted ? {
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
} : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// APPLY_HP_CHANGE — verbatim from ParticipantManager.applyHpChange
|
||||
// changeType: 'damage' | 'heal'
|
||||
function applyHpChange(encounter, participantId, changeType, amount) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
if (isNaN(amount) || amount === 0) {
|
||||
return { patch: null, log: null }; // no-op
|
||||
}
|
||||
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);
|
||||
|
||||
const wasDead = participant.currentHp === 0;
|
||||
const isDead = newHp === 0;
|
||||
const wasResurrected = wasDead && newHp > 0;
|
||||
|
||||
// FEAT-1: death no longer flips isActive or touches turnOrderIds.
|
||||
// Dead participants stay in turn order, nextTurn still visits them, PCs
|
||||
// get their death-save turn. isActive = DM-controlled combatant toggle only.
|
||||
const updatedParticipants = (encounter.participants || []).map(p => {
|
||||
if (p.id !== participantId) return p;
|
||||
const updates = { ...p, currentHp: newHp };
|
||||
if (isDead && !wasDead) {
|
||||
updates.deathSaves = p.deathSaves || 0;
|
||||
updates.isDying = false;
|
||||
}
|
||||
if (wasResurrected) {
|
||||
updates.deathSaves = 0;
|
||||
updates.isDying = false;
|
||||
}
|
||||
return updates;
|
||||
});
|
||||
|
||||
// No turn-order updates on death/revive (FEAT-1).
|
||||
const turnUpdates = {};
|
||||
|
||||
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
||||
const deathSuffix = (isDead && !wasDead)
|
||||
? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated')
|
||||
: '';
|
||||
const resurSuffix = wasResurrected ? ' — Revived' : '';
|
||||
const message = changeType === 'damage'
|
||||
? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`
|
||||
: `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`;
|
||||
|
||||
return {
|
||||
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||
log: {
|
||||
message,
|
||||
undo: {
|
||||
participants: [...(encounter.participants || [])],
|
||||
...((isDead && !wasDead) || wasResurrected ? {
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
} : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// DEATH_SAVE — verbatim from ParticipantManager.handleDeathSaveChange
|
||||
// saveNumber: 1 | 2 | 3. Returns isDying flag if 3rd save hit (client triggers removal animation).
|
||||
function deathSave(encounter, participantId, saveNumber) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const currentSaves = participant.deathSaves || 0;
|
||||
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
|
||||
|
||||
if (newSaves === 3) {
|
||||
// Mark dying — caller waits for animation, then calls removeParticipant.
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
|
||||
);
|
||||
return {
|
||||
patch: { participants: updatedParticipants },
|
||||
log: null,
|
||||
isDying: true,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, deathSaves: newSaves } : p
|
||||
);
|
||||
return { patch: { participants: updatedParticipants }, log: null, isDying: false };
|
||||
}
|
||||
|
||||
// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition
|
||||
function toggleCondition(encounter, participantId, conditionId) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const wasActive = (participant.conditions || []).includes(conditionId);
|
||||
const updatedParticipants = (encounter.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 };
|
||||
});
|
||||
return {
|
||||
patch: { participants: updatedParticipants },
|
||||
log: {
|
||||
message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`,
|
||||
undo: { participants: [...(encounter.participants || [])] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative
|
||||
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
|
||||
function reorderParticipants(encounter, draggedId, targetId) {
|
||||
const participants = [...(encounter.participants || [])];
|
||||
const draggedIndex = participants.findIndex(p => p.id === draggedId);
|
||||
const targetIndex = participants.findIndex(p => p.id === targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
throw new Error('Dragged or target item not found.');
|
||||
}
|
||||
const [removedItem] = participants.splice(draggedIndex, 1);
|
||||
// recompute targetIndex after removal (shift if dragged was before target)
|
||||
const newTargetIndex = participants.findIndex(p => p.id === targetId);
|
||||
participants.splice(newTargetIndex, 0, removedItem);
|
||||
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {};
|
||||
return { patch: { participants, ...turnUpdates }, log: null };
|
||||
}
|
||||
|
||||
// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter
|
||||
function endEncounter(encounter) {
|
||||
return {
|
||||
patch: {
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
currentTurnParticipantId: null,
|
||||
round: 0,
|
||||
turnOrderIds: [],
|
||||
},
|
||||
log: {
|
||||
message: `Combat ended: "${encounter.name}"`,
|
||||
undo: {
|
||||
isStarted: encounter.isStarted ?? false,
|
||||
isPaused: encounter.isPaused ?? false,
|
||||
round: encounter.round ?? 0,
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MAX_HP,
|
||||
DEFAULT_INIT_MOD,
|
||||
MONSTER_DEFAULT_INIT_MOD,
|
||||
generateId,
|
||||
rollD20,
|
||||
formatInitMod,
|
||||
sortParticipantsByInitiative,
|
||||
syncTurnOrder,
|
||||
computeTurnOrderAfterRemoval,
|
||||
computeTurnOrderAfterAddition,
|
||||
makeParticipant,
|
||||
buildCharacterParticipant,
|
||||
buildMonsterParticipant,
|
||||
startEncounter,
|
||||
nextTurn,
|
||||
togglePause,
|
||||
addParticipant,
|
||||
addParticipants,
|
||||
updateParticipant,
|
||||
removeParticipant,
|
||||
toggleParticipantActive,
|
||||
applyHpChange,
|
||||
deathSave,
|
||||
toggleCondition,
|
||||
reorderParticipants,
|
||||
endEncounter,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Mock in-memory Firestore for jest tests.
|
||||
// Reset via resetMockDb() in setupTests.js beforeEach.
|
||||
|
||||
const state = {
|
||||
docs: new Map(), // path -> data
|
||||
subscribers: new Map(), // path -> Set<cb>
|
||||
counter: 0,
|
||||
calls: [], // recorded SDK calls
|
||||
};
|
||||
|
||||
export const MOCK_DB = {
|
||||
get(path) { return state.docs.has(path) ? clone(state.docs.get(path)) : null; },
|
||||
set(path, data) {
|
||||
state.docs.set(path, clone(data));
|
||||
this._notify(path);
|
||||
},
|
||||
merge(path, patch) {
|
||||
const cur = state.docs.has(path) ? state.docs.get(path) : {};
|
||||
const next = { ...cur, ...clone(patch) };
|
||||
state.docs.set(path, next);
|
||||
this._notify(path);
|
||||
},
|
||||
delete(path) {
|
||||
state.docs.delete(path);
|
||||
this._notify(path);
|
||||
},
|
||||
collection(collPath) {
|
||||
const out = [];
|
||||
for (const [p, data] of state.docs) {
|
||||
const parent = p.split('/').slice(0, -1).join('/');
|
||||
if (parent === collPath) out.push({ id: p.split('/').pop(), data: clone(data) });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
subscribe(path, cb) {
|
||||
if (!state.subscribers.has(path)) state.subscribers.set(path, new Set());
|
||||
state.subscribers.get(path).add(cb);
|
||||
return () => state.subscribers.get(path)?.delete(cb);
|
||||
},
|
||||
_notify(path) {
|
||||
// notify exact doc path subscribers
|
||||
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => cb());
|
||||
// notify parent collection subscribers
|
||||
const parent = path.split('/').slice(0, -1).join('/');
|
||||
if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => cb());
|
||||
},
|
||||
nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
|
||||
_state: state,
|
||||
};
|
||||
|
||||
export function recordCall(entry) {
|
||||
state.calls.push({ ...entry, ts: Date.now() });
|
||||
}
|
||||
|
||||
export function resetMockDb() {
|
||||
state.docs.clear();
|
||||
state.subscribers.clear();
|
||||
state.calls.length = 0;
|
||||
state.counter = 0;
|
||||
}
|
||||
|
||||
export function getCalls() {
|
||||
return [...state.calls];
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
if (v === null || v === undefined) return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// jest manual mock: firebase/app
|
||||
const fakeApp = { name: '[fake-firebase-app]', options: {} };
|
||||
export function initializeApp(config) { return fakeApp; }
|
||||
export const getApp = () => fakeApp;
|
||||
export const getApps = () => [fakeApp];
|
||||
@@ -0,0 +1,11 @@
|
||||
// jest manual mock: firebase/auth
|
||||
const fakeUser = { uid: 'test-user-123', isAnonymous: true };
|
||||
const fakeAuth = { currentUser: fakeUser };
|
||||
|
||||
export function getAuth() { return fakeAuth; }
|
||||
export function signInAnonymously(auth) { return Promise.resolve({ user: fakeUser }); }
|
||||
export function signInWithCustomToken(auth, token) { return Promise.resolve({ user: fakeUser }); }
|
||||
export function onAuthStateChanged(auth, cb) {
|
||||
cb(fakeUser);
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// jest manual mock: firebase/firestore
|
||||
// Records all calls so tests can assert path/payload/semantics.
|
||||
// Global __firestoreCalls reset per test (see setupTests.js).
|
||||
|
||||
import { MOCK_DB, recordCall } from './_mock-db.js';
|
||||
|
||||
const ref = (path) => ({ __ref: true, path, id: path.split('/').pop() });
|
||||
|
||||
export function getFirestore() { return { __db: true }; }
|
||||
export function doc(db, path, extra) {
|
||||
const p = extra ? `${path}/${extra}` : path;
|
||||
return ref(p);
|
||||
}
|
||||
export function collection(db, path) { return ref(path); }
|
||||
export function query(refOrColl, ...constraints) { return { ref: refOrColl, constraints }; }
|
||||
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||
export function limit(n) { return { __type: 'limit', n }; }
|
||||
|
||||
// writes
|
||||
export async function setDoc(docRef, data, opts) {
|
||||
recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null });
|
||||
MOCK_DB.set(docRef.path, clone(data));
|
||||
return undefined;
|
||||
}
|
||||
export async function updateDoc(docRef, patch) {
|
||||
recordCall({ fn: 'updateDoc', path: docRef.path, data: clone(patch) });
|
||||
MOCK_DB.merge(docRef.path, clone(patch));
|
||||
return undefined;
|
||||
}
|
||||
export async function deleteDoc(docRef) {
|
||||
recordCall({ fn: 'deleteDoc', path: docRef.path });
|
||||
MOCK_DB.delete(docRef.path);
|
||||
return undefined;
|
||||
}
|
||||
export async function addDoc(collRef, data) {
|
||||
const id = `auto_${MOCK_DB.nextId()}`;
|
||||
const path = `${collRef.path}/${id}`;
|
||||
recordCall({ fn: 'addDoc', path, data: clone(data) });
|
||||
MOCK_DB.set(path, clone(data));
|
||||
return { id, path };
|
||||
}
|
||||
export function writeBatch(db) {
|
||||
const ops = [];
|
||||
return {
|
||||
set: (r, d) => ops.push({ op: 'set', path: r.path, data: clone(d) }),
|
||||
update: (r, d) => ops.push({ op: 'update', path: r.path, data: clone(d) }),
|
||||
delete: (r) => ops.push({ op: 'delete', path: r.path }),
|
||||
commit: async () => {
|
||||
ops.forEach(o => {
|
||||
recordCall({ fn: `batch.${o.op}`, path: o.path, data: o.data });
|
||||
if (o.op === 'set') MOCK_DB.set(o.path, o.data);
|
||||
else if (o.op === 'update') MOCK_DB.merge(o.path, o.data);
|
||||
else if (o.op === 'delete') MOCK_DB.delete(o.path);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// reads (return from in-memory mock DB)
|
||||
export async function getDoc(docRef) {
|
||||
const data = MOCK_DB.get(docRef.path);
|
||||
return { exists: () => data !== null, id: docRef.id, data: () => data };
|
||||
}
|
||||
export async function getDocs(collRefOrQuery) {
|
||||
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||
const docs = MOCK_DB.collection(collPath);
|
||||
return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) };
|
||||
}
|
||||
|
||||
// realtime — emit from mock DB, capture unsub
|
||||
export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
|
||||
// fire immediately with current state
|
||||
const emit = () => {
|
||||
if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) {
|
||||
const data = MOCK_DB.get(path);
|
||||
onSuccess({
|
||||
exists: () => data !== null,
|
||||
id: path.split('/').pop(),
|
||||
data: () => data,
|
||||
});
|
||||
} else {
|
||||
const docs = MOCK_DB.collection(path);
|
||||
onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) });
|
||||
}
|
||||
};
|
||||
emit();
|
||||
// register for future changes on this path
|
||||
const unsub = MOCK_DB.subscribe(path, emit);
|
||||
return unsub;
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
if (v === null || v === undefined) return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
@@ -7,14 +7,9 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: 'Alegreya Sans', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* background-color: #1A202C; /* Tailwind Slate 900 */
|
||||
/* color: #E2E8F0; /* Tailwind Slate 200 */
|
||||
/* These will likely be overridden by the App component's Tailwind classes */
|
||||
}
|
||||
|
||||
code {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// jest setup: RTL jest-dom + mock DB reset per test.
|
||||
import '@testing-library/jest-dom';
|
||||
import { resetMockDb } from './__mocks__/firebase/_mock-db';
|
||||
|
||||
// polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
|
||||
if (!global.crypto) global.crypto = {};
|
||||
if (!global.crypto.randomUUID) {
|
||||
global.crypto.randomUUID = () => 'test-uuid-' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
// Stub Firebase env vars so initializeFirebase() succeeds under test.
|
||||
// Real SDK calls are mocked via __mocks__/firebase/*.
|
||||
process.env.REACT_APP_FIREBASE_API_KEY = 'test-api-key';
|
||||
process.env.REACT_APP_FIREBASE_AUTH_DOMAIN = 'test.firebaseapp.com';
|
||||
process.env.REACT_APP_FIREBASE_PROJECT_ID = 'test-project';
|
||||
process.env.REACT_APP_FIREBASE_STORAGE_BUCKET = 'test.appspot.com';
|
||||
process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID = '1234567890';
|
||||
process.env.REACT_APP_FIREBASE_APP_ID = '1:1234567890:web:abcdef';
|
||||
|
||||
beforeEach(() => {
|
||||
resetMockDb();
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// Storage interface contract.
|
||||
// This is the SPEC. Runs against any storage impl (memory, ws, firebase).
|
||||
// TDD: written first (RED), impl built to satisfy (GREEN).
|
||||
//
|
||||
// Usage:
|
||||
// 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>
|
||||
// setDoc(path, data) -> Promise<void> (replace)
|
||||
// updateDoc(path, patch) -> Promise<void> (shallow merge)
|
||||
// deleteDoc(path) -> Promise<void>
|
||||
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
|
||||
// getCollection(path) -> Promise<arr> (immediate child docs)
|
||||
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
|
||||
// subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null)
|
||||
// subscribeCollection(path, cb) -> unsubscribe fn cb(arr)
|
||||
|
||||
function runStorageContract(name, factory) {
|
||||
describe(`storage contract: ${name}`, () => {
|
||||
let storage;
|
||||
beforeEach(async () => { storage = await factory(); });
|
||||
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
|
||||
|
||||
describe('getDoc / setDoc', () => {
|
||||
test('setDoc then getDoc returns the doc', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('getDoc on missing path returns null', async () => {
|
||||
const doc = await storage.getDoc('campaigns/missing');
|
||||
expect(doc).toBeNull();
|
||||
});
|
||||
|
||||
test('setDoc overwrites entirely (not merge)', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
||||
await storage.setDoc('campaigns/a', { name: 'Beta' });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Beta' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDoc (shallow merge)', () => {
|
||||
test('merges patch into existing doc', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
|
||||
await storage.updateDoc('campaigns/a', { players: [1, 2] });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Alpha', players: [1, 2] });
|
||||
});
|
||||
|
||||
test('updateDoc on missing doc creates it', async () => {
|
||||
await storage.updateDoc('campaigns/a', { name: 'Alpha' });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Alpha' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDoc', () => {
|
||||
test('removes doc', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
await storage.deleteDoc('campaigns/a');
|
||||
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||
});
|
||||
|
||||
test('delete missing doc is no-op (no throw)', async () => {
|
||||
await expect(storage.deleteDoc('campaigns/none')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDoc', () => {
|
||||
test('auto-generates id and stores doc at collection/id', async () => {
|
||||
const { id, path } = await storage.addDoc('campaigns/a/encounters', { name: 'E1' });
|
||||
expect(id).toBeTruthy();
|
||||
expect(path).toBe(`campaigns/a/encounters/${id}`);
|
||||
const doc = await storage.getDoc(path);
|
||||
expect(doc).toEqual({ name: 'E1' });
|
||||
});
|
||||
|
||||
test('two addDocs produce distinct ids', async () => {
|
||||
const r1 = await storage.addDoc('logs', { m: 'one' });
|
||||
const r2 = await storage.addDoc('logs', { m: 'two' });
|
||||
expect(r1.id).not.toBe(r2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firebase-prefixed path identity', () => {
|
||||
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
|
||||
// Adapter must normalize internally so write+read at prefixed path round-trips
|
||||
// AND collection queries at bare canonical path find prefixed-written docs.
|
||||
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed).
|
||||
const PREFIX = 'artifacts/test-app/public/data';
|
||||
|
||||
test('setDoc prefixed then getCollection bare finds it', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
|
||||
const docs = await storage.getCollection('campaigns');
|
||||
expect(docs.some(d => d.name === 'P1')).toBe(true);
|
||||
});
|
||||
|
||||
test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
|
||||
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
|
||||
expect(doc).toEqual({ name: 'P2' });
|
||||
});
|
||||
|
||||
test('setDoc prefixed then getDoc bare path returns it', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
|
||||
const doc = await storage.getDoc('campaigns/c3');
|
||||
expect(doc).toEqual({ name: 'P3' });
|
||||
});
|
||||
|
||||
test('setDoc bare then getCollection prefixed finds it', async () => {
|
||||
await storage.setDoc('campaigns/c4', { name: 'P4' });
|
||||
const docs = await storage.getCollection(`${PREFIX}/campaigns`);
|
||||
expect(docs.some(d => d.name === 'P4')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCollection', () => {
|
||||
test('returns immediate child docs only (not nested)', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||
await storage.setDoc('campaigns/b', { name: 'B' });
|
||||
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
||||
const docs = await storage.getCollection('campaigns');
|
||||
expect(docs).toHaveLength(2);
|
||||
const names = docs.map(d => d.name).sort();
|
||||
expect(names).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
test('empty collection returns []', async () => {
|
||||
const docs = await storage.getCollection('campaigns');
|
||||
expect(docs).toEqual([]);
|
||||
});
|
||||
|
||||
test('subcollection returns only its direct children', async () => {
|
||||
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
||||
await storage.setDoc('campaigns/a/encounters/e2', { name: 'E2' });
|
||||
await storage.setDoc('campaigns/a/encounters/e1/participants/p1', { name: 'P1' });
|
||||
const docs = await storage.getCollection('campaigns/a/encounters');
|
||||
expect(docs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchWrite', () => {
|
||||
test('applies multiple deletes atomically', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||
await storage.setDoc('campaigns/b', { name: 'B' });
|
||||
await storage.batchWrite([
|
||||
{ type: 'delete', path: 'campaigns/a' },
|
||||
{ type: 'delete', path: 'campaigns/b' },
|
||||
]);
|
||||
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||
expect(await storage.getDoc('campaigns/b')).toBeNull();
|
||||
});
|
||||
|
||||
test('applies set + delete mixed', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||
await storage.batchWrite([
|
||||
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
|
||||
{ type: 'delete', path: 'campaigns/a' },
|
||||
]);
|
||||
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||
expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeDoc', () => {
|
||||
test('fires cb immediately with current value', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
const calls = [];
|
||||
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({ name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('fires cb on subsequent change', async () => {
|
||||
const calls = [];
|
||||
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||
await flush();
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
await flush();
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toEqual({ name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('unsubscribe stops callbacks', async () => {
|
||||
const calls = [];
|
||||
const unsub = storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||
await flush();
|
||||
unsub();
|
||||
await storage.setDoc('campaigns/a', { name: 'X' });
|
||||
await flush();
|
||||
expect(calls.filter(Boolean)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeCollection', () => {
|
||||
test('fires cb with current docs', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||
const calls = [];
|
||||
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('fires on add to collection', async () => {
|
||||
const calls = [];
|
||||
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
||||
await flush();
|
||||
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||
await flush();
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// flush so async subscribers settle. WS roundtrip needs real delay (network),
|
||||
// memory fires near-instant. 50ms covers localhost WS comfortably.
|
||||
function flush() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
module.exports = { runStorageContract, flush };
|
||||
@@ -0,0 +1,148 @@
|
||||
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
|
||||
// Matches interface of memory.js / ws.js so App.js calls stay identical.
|
||||
//
|
||||
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim.
|
||||
// Two-phase refactor:
|
||||
// Phase A (now): adapter exists, wraps SDK. Hooks/writes can switch incrementally.
|
||||
// Phase B (later): App.js imports storage factory, drops direct SDK imports.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
||||
import {
|
||||
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
|
||||
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, serverTimestamp,
|
||||
} from 'firebase/firestore';
|
||||
|
||||
// Adapter call recorder (instrumentation, no behavior change).
|
||||
// Tests assert adapter.subscribeDoc called (catches raw-SDK bypass like DisplayView).
|
||||
const ADAPTER_CALLS = [];
|
||||
function recordAdapterCall(entry) { ADAPTER_CALLS.push({ ...entry, ts: Date.now() }); }
|
||||
export function getAdapterCalls() { return [...ADAPTER_CALLS]; }
|
||||
export function resetAdapterCalls() { ADAPTER_CALLS.length = 0; }
|
||||
|
||||
// Path helpers mirror App.js getPath object.
|
||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
||||
|
||||
export const getPath = {
|
||||
campaigns: () => `${PUBLIC_DATA_PATH}/campaigns`,
|
||||
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
|
||||
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
|
||||
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
|
||||
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
|
||||
logs: () => `${PUBLIC_DATA_PATH}/logs`
|
||||
};
|
||||
|
||||
let firebaseApp = null;
|
||||
let dbInstance = null;
|
||||
let authInstance = null;
|
||||
|
||||
export function initFirebase() {
|
||||
const config = {
|
||||
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
||||
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
||||
};
|
||||
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
||||
const missing = requiredKeys.filter(k => !config[k]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`CRITICAL: Missing Firebase config: ${missing.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
firebaseApp = initializeApp(config);
|
||||
dbInstance = getFirestore(firebaseApp);
|
||||
authInstance = getAuth(firebaseApp);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Firebase init failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb() { return dbInstance; }
|
||||
export function getAuthInstance() { return authInstance; }
|
||||
|
||||
// ============================================================================
|
||||
// STORAGE ADAPTER
|
||||
// ============================================================================
|
||||
// Wraps SDK in the storage interface (getDoc/setDoc/etc).
|
||||
// App.js can now import { storage } and call storage.setDoc(path, data).
|
||||
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
|
||||
|
||||
export function createFirebaseStorage() {
|
||||
const db = dbInstance;
|
||||
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
|
||||
|
||||
return {
|
||||
async getDoc(path) {
|
||||
const snap = await getDocReal(doc(db, path));
|
||||
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
|
||||
},
|
||||
|
||||
async setDoc(path, data, opts = {}) {
|
||||
recordAdapterCall({ fn: 'setDoc', path, data, opts });
|
||||
await setDoc(doc(db, path), data, opts.merge ? { merge: true } : undefined);
|
||||
},
|
||||
|
||||
async updateDoc(path, patch) {
|
||||
recordAdapterCall({ fn: 'updateDoc', path, patch });
|
||||
await updateDoc(doc(db, path), patch);
|
||||
},
|
||||
|
||||
async deleteDoc(path) {
|
||||
await deleteDoc(doc(db, path));
|
||||
},
|
||||
|
||||
async addDoc(collectionPath, data) {
|
||||
const ref = await addDoc(collection(db, collectionPath), data);
|
||||
return { id: ref.id, path: `${collectionPath}/${ref.id}` };
|
||||
},
|
||||
|
||||
async getCollection(collectionPath) {
|
||||
const snapshot = await getDocsReal(collection(db, collectionPath));
|
||||
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
const batch = writeBatch(db);
|
||||
for (const op of ops) {
|
||||
if (op.type === 'set') batch.set(doc(db, op.path), op.data);
|
||||
else if (op.type === 'delete') batch.delete(doc(db, op.path));
|
||||
else if (op.type === 'update') batch.update(doc(db, op.path), op.data);
|
||||
}
|
||||
await batch.commit();
|
||||
},
|
||||
|
||||
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
||||
subscribeDoc(path, cb) {
|
||||
recordAdapterCall({ fn: 'subscribeDoc', path });
|
||||
return onSnapshot(doc(db, path), (snap) => {
|
||||
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
|
||||
}, (err) => console.error(`subscribeDoc ${path}:`, err));
|
||||
},
|
||||
|
||||
subscribeCollection(collectionPath, cb, queryConstraints = []) {
|
||||
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
||||
const q = queryConstraints.length > 0
|
||||
? query(collection(db, collectionPath), ...queryConstraints)
|
||||
: collection(db, collectionPath);
|
||||
return onSnapshot(q, (snap) => {
|
||||
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
||||
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err));
|
||||
},
|
||||
|
||||
dispose() { /* SDK managed; no-op */ },
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export SDK pieces App.js uses directly (until full refactor).
|
||||
export {
|
||||
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
|
||||
query, orderBy, limit, writeBatch,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// src/storage/index.js — storage factory + SDK re-exports.
|
||||
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=ws: backend.
|
||||
// App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending).
|
||||
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import {
|
||||
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
|
||||
} from 'firebase/auth';
|
||||
import {
|
||||
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
|
||||
} from 'firebase/firestore';
|
||||
import { initFirebase, createFirebaseStorage } from './firebase';
|
||||
import { createWsStorage } from './ws';
|
||||
import { createMemoryStorage } from './memory';
|
||||
|
||||
let storageInstance = null;
|
||||
|
||||
// Returns adapter instance implementing interface (getDoc/setDoc/subscribeDoc/etc).
|
||||
export function getStorage() {
|
||||
if (storageInstance) return storageInstance;
|
||||
const mode = process.env.REACT_APP_STORAGE || 'firebase';
|
||||
if (mode === 'firebase') {
|
||||
const ok = initFirebase();
|
||||
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
|
||||
storageInstance = createFirebaseStorage();
|
||||
} else if (mode === 'ws') {
|
||||
storageInstance = createWsStorage({
|
||||
baseUrl: process.env.REACT_APP_BACKEND_URL || '',
|
||||
wsUrl: process.env.REACT_APP_BACKEND_WS || '',
|
||||
});
|
||||
} else {
|
||||
storageInstance = createMemoryStorage();
|
||||
}
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
export function getStorageMode() {
|
||||
return process.env.REACT_APP_STORAGE || 'firebase';
|
||||
}
|
||||
|
||||
export {
|
||||
initializeApp,
|
||||
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
|
||||
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
// memory.js — in-process storage impl. Test seed.
|
||||
// Map<docPath, data>. EventEmitter for subscribe.
|
||||
// Mirrors firebase semantics: setDoc=replace, updateDoc=shallow merge, addDoc=auto-id.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const docs = new Map(); // path -> data obj
|
||||
const bus = new EventEmitter();
|
||||
bus.setMaxListeners(1000);
|
||||
|
||||
// Firebase-prefixed paths (artifacts/{APP_ID}/public/data/...) normalized to
|
||||
// bare canonical. Matches ws.js norm() so all impls share path identity.
|
||||
function norm(p) {
|
||||
if (!p) return p;
|
||||
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||
}
|
||||
|
||||
// ---- path helpers ----
|
||||
// collection path = path with even number of segments OR known collection.
|
||||
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
|
||||
// getCollection(path) returns all docs whose path === path/id for any single id segment.
|
||||
function isCollectionPath(p) {
|
||||
return p.split('/').length % 2 === 1;
|
||||
}
|
||||
|
||||
function emitDoc(path, data) { bus.emit('doc:' + path, data); }
|
||||
function emitCollection(collPath) {
|
||||
const children = collectionDocs(collPath);
|
||||
bus.emit('coll:' + collPath, children);
|
||||
}
|
||||
|
||||
function collectionDocs(collPath) {
|
||||
const out = [];
|
||||
const segLen = collPath.split('/').length + 1;
|
||||
for (const [p, data] of docs) {
|
||||
const segs = p.split('/');
|
||||
if (segs.length !== segLen) continue;
|
||||
const parent = segs.slice(0, -1).join('/');
|
||||
if (parent === collPath) out.push(data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function genId() {
|
||||
return (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
const storage = {
|
||||
async getDoc(rawPath) {
|
||||
const path = norm(rawPath);
|
||||
return docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||
},
|
||||
|
||||
async setDoc(rawPath, data) {
|
||||
const path = norm(rawPath);
|
||||
docs.set(path, deepClone(data));
|
||||
emitDoc(path, deepClone(data));
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async updateDoc(rawPath, patch) {
|
||||
const path = norm(rawPath);
|
||||
const existing = docs.has(path) ? docs.get(path) : {};
|
||||
const merged = { ...existing, ...patch };
|
||||
docs.set(path, merged);
|
||||
emitDoc(path, deepClone(merged));
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async deleteDoc(rawPath) {
|
||||
const path = norm(rawPath);
|
||||
docs.delete(path);
|
||||
emitDoc(path, null);
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async addDoc(rawCollectionPath, data) {
|
||||
const collectionPath = norm(rawCollectionPath);
|
||||
const id = genId();
|
||||
const path = `${collectionPath}/${id}`;
|
||||
docs.set(path, deepClone(data));
|
||||
emitDoc(path, deepClone(data));
|
||||
emitCollection(collectionPath);
|
||||
return { id, path };
|
||||
},
|
||||
|
||||
async getCollection(rawCollPath) {
|
||||
const collPath = norm(rawCollPath);
|
||||
return collectionDocs(collPath).map(deepClone);
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
for (const op of ops) {
|
||||
const mop = { ...op, path: norm(op.path) };
|
||||
if (mop.type === 'set') await storage.setDoc(mop.path, mop.data);
|
||||
else if (mop.type === 'delete') await storage.deleteDoc(mop.path);
|
||||
else if (mop.type === 'update') await storage.updateDoc(mop.path, mop.data);
|
||||
}
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb) {
|
||||
const path = norm(rawPath);
|
||||
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||
Promise.resolve().then(() => cb(cur));
|
||||
const handler = (data) => cb(data);
|
||||
bus.on('doc:' + path, handler);
|
||||
return () => bus.off('doc:' + path, handler);
|
||||
},
|
||||
|
||||
subscribeCollection(rawCollPath, cb) {
|
||||
const collPath = norm(rawCollPath);
|
||||
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
|
||||
const handler = (docs) => cb(docs);
|
||||
bus.on('coll:' + collPath, handler);
|
||||
return () => bus.off('coll:' + collPath, handler);
|
||||
},
|
||||
|
||||
dispose() { bus.removeAllListeners(); docs.clear(); },
|
||||
|
||||
// test/debug
|
||||
_docs: docs,
|
||||
};
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
function deepClone(v) {
|
||||
if (v === null || v === undefined) return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
export { createMemoryStorage };
|
||||
@@ -0,0 +1,237 @@
|
||||
// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket).
|
||||
// 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;
|
||||
if (typeof WebSocket !== 'undefined') {
|
||||
WebSocketImpl = WebSocket;
|
||||
}
|
||||
|
||||
function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
// Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy).
|
||||
// Fallback to localhost for bare `npm start` dev without proxy.
|
||||
const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, '');
|
||||
let WS;
|
||||
if (wsUrl) {
|
||||
WS = wsUrl;
|
||||
} else if (typeof window !== 'undefined' && window.location) {
|
||||
// derive from current origin (http→ws, https→wss), same host/port.
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
WS = `${proto}//${window.location.host}/ws`;
|
||||
} else {
|
||||
WS = 'ws://127.0.0.1:4001/ws';
|
||||
}
|
||||
|
||||
// App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
|
||||
// Backend uses canonical paths. Strip prefix.
|
||||
function norm(p) {
|
||||
if (!p) return p;
|
||||
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||
}
|
||||
|
||||
const docSubs = new Map(); // path -> Set<cb>
|
||||
const collSubs = new Map(); // collPath -> Set<cb>
|
||||
let ws = null;
|
||||
let wsReady = null;
|
||||
|
||||
let disposed = false;
|
||||
let reconnectTimer = null;
|
||||
let everConnected = false;
|
||||
const RECONNECT_DELAY = 500;
|
||||
|
||||
function ensureWs() {
|
||||
if (wsReady) return wsReady;
|
||||
wsReady = new Promise((resolve, reject) => {
|
||||
(async () => {
|
||||
// Node/jest only: load ws pkg via dynamic import. Browser uses global
|
||||
// WebSocket. Avoids require() in CRA prod ESM bundle (webpack crash).
|
||||
let WsClass = WebSocketImpl;
|
||||
if (!WsClass) {
|
||||
const wsPkg = await import('ws');
|
||||
WsClass = wsPkg.WebSocket;
|
||||
}
|
||||
ws = new WsClass(WS);
|
||||
const onOpen = () => {
|
||||
const isReconnect = everConnected;
|
||||
everConnected = true;
|
||||
// resubscribe all existing subscribers after (re)connect
|
||||
for (const p of docSubs.keys()) {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||
}
|
||||
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
|
||||
// happened while disconnected (broadcast missed). Skip on first connect
|
||||
// (initial REST fetch in subscribeDoc/subscribeCollection already did).
|
||||
if (isReconnect) {
|
||||
for (const [p, cbs] of docSubs) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
resolve(ws);
|
||||
};
|
||||
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
|
||||
const onClose = () => {
|
||||
wsReady = null;
|
||||
ws = null;
|
||||
if (disposed) return;
|
||||
// auto-reconnect (BUG-8): try again after delay. ensureWs() re-arms.
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (!disposed) ensureWs().catch(() => {});
|
||||
}, RECONNECT_DELAY);
|
||||
if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref();
|
||||
};
|
||||
const onMessage = (ev) => {
|
||||
const raw = typeof ev === 'string' ? ev : (ev.data !== undefined ? ev.data : ev);
|
||||
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
|
||||
handleMessage(msg);
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
|
||||
async function handleMessage(msg) {
|
||||
if (msg.type !== 'change' || !msg.change) return;
|
||||
const c = msg.change;
|
||||
// doc subscriber at exact changed path
|
||||
const docCbs = docSubs.get(c.path);
|
||||
if (docCbs) {
|
||||
const doc = await storage.getDoc(c.path);
|
||||
docCbs.forEach(cb => cb(doc));
|
||||
}
|
||||
// collection subscribers at parent path (doc belongs to this collection)
|
||||
if (c.parent) {
|
||||
const collCbs = collSubs.get(c.parent);
|
||||
if (collCbs) {
|
||||
const docs = await storage.getCollection(c.parent);
|
||||
collCbs.forEach(cb => cb(docs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function api(method, path, query, body) {
|
||||
let url = `${API}${path}`;
|
||||
if (query) {
|
||||
const qs = new URLSearchParams(query).toString();
|
||||
url += `?${qs}`;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => '');
|
||||
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
const storage = {
|
||||
async getDoc(rawPath) {
|
||||
const p = norm(rawPath);
|
||||
const res = await api('GET', '/api/doc', { path: p });
|
||||
return res && res.data !== undefined ? res.data : null;
|
||||
},
|
||||
|
||||
async setDoc(rawPath, data) {
|
||||
const p = norm(rawPath);
|
||||
await api('PUT', '/api/doc', null, { path: p, data });
|
||||
},
|
||||
|
||||
async updateDoc(rawPath, patch) {
|
||||
const p = norm(rawPath);
|
||||
await api('PATCH', '/api/doc', null, { path: p, patch });
|
||||
},
|
||||
|
||||
async deleteDoc(rawPath) {
|
||||
const p = norm(rawPath);
|
||||
await api('DELETE', '/api/doc', { path: p });
|
||||
},
|
||||
|
||||
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) {
|
||||
const p = norm(rawCollPath);
|
||||
return await api('GET', '/api/collection', { path: p });
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
|
||||
await api('POST', '/api/batch', null, { ops: normOps });
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb) {
|
||||
const p = norm(rawPath);
|
||||
// Initial value via REST (independent of WS connect).
|
||||
storage.getDoc(p).then(cb).catch(() => {});
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||
}).catch(() => {});
|
||||
if (!docSubs.has(p)) docSubs.set(p, new Set());
|
||||
docSubs.get(p).add(cb);
|
||||
return () => { docSubs.get(p)?.delete(cb); };
|
||||
},
|
||||
|
||||
subscribeCollection(rawCollPath, cb) {
|
||||
const p = norm(rawCollPath);
|
||||
// Initial value via REST (independent of WS connect).
|
||||
storage.getCollection(p).then(cb).catch(() => {});
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||
}).catch(() => {});
|
||||
if (!collSubs.has(p)) collSubs.set(p, new Set());
|
||||
collSubs.get(p).add(cb);
|
||||
return () => { collSubs.get(p)?.delete(cb); };
|
||||
},
|
||||
|
||||
dispose(cb) {
|
||||
disposed = true;
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
if (ws) ws.close();
|
||||
docSubs.clear(); collSubs.clear();
|
||||
if (typeof cb === 'function') cb();
|
||||
},
|
||||
|
||||
_api: api,
|
||||
_test: {
|
||||
getWs: () => ws,
|
||||
forceDrop: () => { if (ws) ws.close(); },
|
||||
getReady: () => wsReady,
|
||||
docSubs, collSubs,
|
||||
},
|
||||
};
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
export { createWsStorage };
|
||||
@@ -0,0 +1,142 @@
|
||||
// App.characterization.test.js
|
||||
// Characterize App -> Firebase calls. Lock path + payload shape per action.
|
||||
// Mock SDK, render AdminView, fire action, assert recorded calls.
|
||||
// Purpose: refactor (path-shape rewrite) must not change these calls.
|
||||
|
||||
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';
|
||||
|
||||
function findCall(fn, pathSub) {
|
||||
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||
}
|
||||
function findCalls(fn, pathSub) {
|
||||
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CAMPAIGN GROUP
|
||||
// ============================================================================
|
||||
|
||||
describe('Campaign -> Firebase', () => {
|
||||
test('createCampaign: setDoc with campaign path + payload', async () => {
|
||||
await renderApp();
|
||||
const id = await createCampaignViaUI('Alpha');
|
||||
const call = findCall('setDoc', '/campaigns/');
|
||||
expect(call.path).toMatch(/campaigns\/.+$/);
|
||||
expect(call.data).toMatchObject({
|
||||
name: 'Alpha',
|
||||
playerDisplayBackgroundUrl: '',
|
||||
players: [],
|
||||
});
|
||||
expect(call.data).toHaveProperty('ownerId');
|
||||
expect(call.data).toHaveProperty('createdAt');
|
||||
});
|
||||
|
||||
test('createCampaign: path includes APP_ID namespace', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('NS Test');
|
||||
const call = findCall('setDoc', '/campaigns/');
|
||||
expect(call.path).toContain('artifacts/');
|
||||
expect(call.path).toContain('/public/data/');
|
||||
});
|
||||
|
||||
test('createCampaign: optional background URL stored', async () => {
|
||||
await renderApp();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'With BG' } });
|
||||
fireEvent.change(screen.getByLabelText(/Background URL/i), { target: { value: 'https://img.test/bg.png' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
await waitFor(() => findCall('setDoc', '/campaigns/'));
|
||||
const call = findCall('setDoc', '/campaigns/');
|
||||
expect(call.data.playerDisplayBackgroundUrl).toBe('https://img.test/bg.png');
|
||||
});
|
||||
|
||||
test('addCharacter: updateDoc on campaign doc, players array grows', async () => {
|
||||
await renderApp();
|
||||
const cid = await createCampaignViaUI('Roster');
|
||||
await selectCampaignByName('Roster');
|
||||
|
||||
// CharacterManager form
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
|
||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
|
||||
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
|
||||
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||
const call = findCall('updateDoc', `/campaigns/${cid}`);
|
||||
expect(call.data.players).toHaveLength(1);
|
||||
expect(call.data.players[0]).toMatchObject({
|
||||
name: 'Brog',
|
||||
defaultMaxHp: 25,
|
||||
defaultInitMod: 3,
|
||||
});
|
||||
expect(call.data.players[0]).toHaveProperty('id');
|
||||
});
|
||||
|
||||
test('updateCharacter: updateDoc with updated players array', async () => {
|
||||
await renderApp();
|
||||
const cid = await createCampaignViaUI('EditRoster');
|
||||
await selectCampaignByName('EditRoster');
|
||||
|
||||
// add one first
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Old Name' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||
|
||||
// click edit
|
||||
const editBtn = await screen.findByRole('button', { name: /Edit character/i });
|
||||
fireEvent.click(editBtn);
|
||||
await waitFor(() => screen.getByDisplayValue('Old Name'));
|
||||
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
|
||||
// Save button is icon-only (no text); submit its form.
|
||||
const form = screen.getByDisplayValue('New Name').closest('form');
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last.data.players[0].name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
test('deleteCharacter: updateDoc with character removed', async () => {
|
||||
await renderApp();
|
||||
const cid = await createCampaignViaUI('DeleteRoster');
|
||||
await selectCampaignByName('DeleteRoster');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Gone' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||
|
||||
const delBtn = await screen.findByRole('button', { name: /Delete character/i });
|
||||
fireEvent.click(delBtn);
|
||||
// confirmation modal
|
||||
fireEvent.click(screen.getByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last.data.players).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('deleteCampaign: deletes encounters batch + campaign doc + activeDisplay null', async () => {
|
||||
await renderApp();
|
||||
const cid = await createCampaignViaUI('Doomed');
|
||||
await selectCampaignByName('Doomed');
|
||||
|
||||
// campaign card delete button has no aria-label; find trash by text via grid
|
||||
const allDeletes = screen.getAllByText(/Delete/i);
|
||||
// campaign card Delete is in card grid, last one rendered
|
||||
fireEvent.click(allDeletes[allDeletes.length - 1]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
|
||||
const delCall = findCall('deleteDoc', `/campaigns/${cid}`);
|
||||
expect(delCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Combat characterization. Lock updateDoc/setDoc patch for combat controls.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||
|
||||
function findCallsEnc() {
|
||||
return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
}
|
||||
function lastEncCall() {
|
||||
const calls = findCallsEnc();
|
||||
return calls[calls.length - 1];
|
||||
}
|
||||
function findCallActiveDisplay(fn) {
|
||||
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
|
||||
}
|
||||
|
||||
async function setupWithMonsters(names = ['A', 'B', 'C']) {
|
||||
await setupReady('CombatCamp', 'CombatEnc');
|
||||
for (const n of names) {
|
||||
await addMonsterViaUI(n, 20, Number(n.charCodeAt(0) % 10));
|
||||
}
|
||||
}
|
||||
|
||||
describe('Combat -> Firebase', () => {
|
||||
test('startEncounter: updateDoc sets isStarted/round/turn/current', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
const call = lastEncCall();
|
||||
expect(call.data).toMatchObject({
|
||||
isStarted: true,
|
||||
isPaused: false,
|
||||
round: 1,
|
||||
});
|
||||
expect(call.data.currentTurnParticipantId).toBeTruthy();
|
||||
expect(call.data.turnOrderIds).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('startEncounter: also sets activeDisplay to this encounter', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data.activeCampaignId).toBeTruthy();
|
||||
expect(last.data.activeEncounterId).toBeTruthy();
|
||||
});
|
||||
|
||||
test('nextTurn: advances currentTurnParticipantId', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
const beforeId = lastEncCall().data.currentTurnParticipantId;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId !== beforeId);
|
||||
expect(lastEncCall().data.currentTurnParticipantId).not.toBe(beforeId);
|
||||
});
|
||||
|
||||
test('nextTurn wrapping to round 1->2 increments round', async () => {
|
||||
await setupWithMonsters(['A', 'B']);
|
||||
await startCombatViaUI();
|
||||
|
||||
// advance through all participants to wrap
|
||||
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // A->B (or 2nd)
|
||||
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // wrap
|
||||
await waitFor(() => lastEncCall()?.data?.round === 2);
|
||||
expect(lastEncCall().data.round).toBe(2);
|
||||
});
|
||||
|
||||
test('pause: updateDoc sets isPaused true', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.isPaused === true);
|
||||
expect(lastEncCall().data.isPaused).toBe(true);
|
||||
});
|
||||
|
||||
test('resume: updateDoc sets isPaused false + recomputes turnOrder', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.isPaused === true);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.isPaused === false);
|
||||
const call = lastEncCall();
|
||||
expect(call.data.isPaused).toBe(false);
|
||||
expect(call.data.turnOrderIds).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('endEncounter: updateDoc resets all combat state', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.isStarted === false);
|
||||
const call = lastEncCall();
|
||||
expect(call.data).toMatchObject({
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('endEncounter: clears activeDisplay', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && last.data.activeCampaignId === null;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
const switchBtn = screen.getByRole('switch');
|
||||
fireEvent.click(switchBtn);
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && 'hidePlayerHp' in last.data;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toHaveProperty('hidePlayerHp');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
// Combat.scenario.test.js
|
||||
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of
|
||||
// damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove.
|
||||
// Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run:
|
||||
// each phase wraps in try/catch, failures collected, final expect reports all.
|
||||
//
|
||||
// Purpose: exercise as much of the supported feature surface as possible in one
|
||||
// long combat, surfacing behavioral bugs characterization tests miss.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import App from '../App';
|
||||
import {
|
||||
renderApp, createCampaignViaUI, selectCampaignByName,
|
||||
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
|
||||
} from './testHelpers';
|
||||
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
|
||||
// ---------- scenario helpers (UI only, same buttons as human) ----------
|
||||
|
||||
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) {
|
||||
try { await fn(); RESULTS.push({ phase, ok: true }); }
|
||||
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||
}
|
||||
|
||||
function getParticipantForm() {
|
||||
const heading = screen.getByText('Add Participants');
|
||||
let node = heading;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
node = node.parentElement;
|
||||
if (!node) break;
|
||||
if (node.querySelector('form')) return within(node);
|
||||
}
|
||||
return within(heading.parentElement);
|
||||
}
|
||||
|
||||
// Find a participant's encounter <li> row by name. Scoped to the encounter
|
||||
// participant list (NOT the CharacterManager roster, which also shows names).
|
||||
// Encounter participant rows render an 'Init:' label; roster rows do not.
|
||||
function getParticipantRow(name) {
|
||||
const lis = document.querySelectorAll('li');
|
||||
for (const li of lis) {
|
||||
const txt = li.textContent || '';
|
||||
if (txt.includes('Init:') && txt.includes(name)) {
|
||||
return within(li);
|
||||
}
|
||||
}
|
||||
throw new Error(`encounter participant row not found: ${name}`);
|
||||
}
|
||||
|
||||
// Character roster (CharacterManager). Assumes campaign selected.
|
||||
async function addCharacterViaUI(name, maxHp, initMod) {
|
||||
fireEvent.change(document.getElementById('characterName'), { target: { value: name } });
|
||||
fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
|
||||
fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
|
||||
await waitFor(() => {
|
||||
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));
|
||||
if (!call) throw new Error('char not persisted');
|
||||
});
|
||||
}
|
||||
|
||||
function setParticipantType(type) {
|
||||
// The Type select is inside the Add Participants form.
|
||||
const form = getParticipantForm();
|
||||
const selects = form.getAllByRole('combobox');
|
||||
// first combobox in the participant form is Type
|
||||
fireEvent.change(selects[0], { target: { value: type } });
|
||||
}
|
||||
|
||||
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||
const form = getParticipantForm();
|
||||
setParticipantType('monster');
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
|
||||
if (isNpc) {
|
||||
const npcCheck = form.getByRole('checkbox', { name: /NPC/i });
|
||||
if (!npcCheck.checked) fireEvent.click(npcCheck);
|
||||
}
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
async function addCharacterParticipant(charName) {
|
||||
const form = getParticipantForm();
|
||||
setParticipantType('character');
|
||||
// character select is the 2nd combobox in the form after Type
|
||||
const charSelect = form.getAllByRole('combobox')[1];
|
||||
// find option whose text includes the char name
|
||||
const opt = [...charSelect.querySelectorAll('option')].find(o => o.textContent.includes(charName));
|
||||
if (!opt) throw new Error(`char option not found: ${charName}`);
|
||||
fireEvent.change(charSelect, { target: { value: opt.value } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
async function addAllCharacters() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add All/i }));
|
||||
await waitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last) throw new Error('add all no-op');
|
||||
});
|
||||
}
|
||||
|
||||
function applyDamage(name, amount) {
|
||||
const row = getParticipantRow(name);
|
||||
const dmgBtn = row.queryByTitle('Damage');
|
||||
if (!dmgBtn) {
|
||||
// participant dead (Damage button hidden when currentHp===0). Expected game
|
||||
// state over a long fight; not a bug. Skip silently.
|
||||
return;
|
||||
}
|
||||
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
|
||||
fireEvent.click(dmgBtn);
|
||||
}
|
||||
function applyHeal(name, amount) {
|
||||
const row = getParticipantRow(name);
|
||||
const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal');
|
||||
if (!healBtn) throw new Error(`${name} has no Heal button`);
|
||||
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
|
||||
fireEvent.click(healBtn);
|
||||
}
|
||||
function toggleActive(name) {
|
||||
const row = getParticipantRow(name);
|
||||
const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive');
|
||||
if (!btn) throw new Error(`${name} has no active toggle`);
|
||||
fireEvent.click(btn);
|
||||
}
|
||||
function openConditions(name) {
|
||||
const row = getParticipantRow(name);
|
||||
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);
|
||||
}
|
||||
function toggleCondition(name, label) {
|
||||
openConditions(name);
|
||||
// panel render is async (React state). Wait for button by title.
|
||||
return waitFor(() => {
|
||||
const condButtons = document.querySelectorAll('button[title]');
|
||||
const target = [...condButtons].find(b => b.getAttribute('title') === label);
|
||||
if (!target) throw new Error(`condition button not found: ${label}`);
|
||||
fireEvent.click(target);
|
||||
});
|
||||
}
|
||||
function editParticipant(name, patch) {
|
||||
const row = getParticipantRow(name);
|
||||
fireEvent.click(row.getByTitle('Edit'));
|
||||
// EditParticipantModal. Scope to the modal via its form inputs.
|
||||
const modal = document.querySelector('.fixed.inset-0') || document.body;
|
||||
const inputs = modal.querySelectorAll('input');
|
||||
if (patch.name !== undefined) {
|
||||
fireEvent.change(inputs[0], { target: { value: patch.name } });
|
||||
}
|
||||
if (patch.initiative !== undefined && inputs[1]) {
|
||||
fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } });
|
||||
}
|
||||
const saveBtn = modal.querySelector('button[type="submit"]') ||
|
||||
[...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim()));
|
||||
fireEvent.click(saveBtn);
|
||||
}
|
||||
function removeParticipant(name) {
|
||||
fireEvent.click(getParticipantRow(name).getByTitle('Remove'));
|
||||
}
|
||||
async function deathSave(name, saveNum) {
|
||||
const row = getParticipantRow(name);
|
||||
const btn = row.getByTitle(`Death save ${saveNum}`);
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last) throw new Error('deathSave no write');
|
||||
});
|
||||
}
|
||||
async function nextTurn() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
|
||||
await waitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last) throw new Error('nextTurn no write');
|
||||
});
|
||||
}
|
||||
async function pauseCombat() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||
await waitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last?.data?.isPaused) throw new Error('not paused');
|
||||
});
|
||||
}
|
||||
async function resumeCombat() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
|
||||
await waitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (last?.data?.isPaused) throw new Error('not resumed');
|
||||
});
|
||||
}
|
||||
async function startCombat() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
|
||||
await waitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last?.data?.isStarted) throw new Error('not started');
|
||||
});
|
||||
}
|
||||
function toggleHidePlayerHp() {
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
}
|
||||
function currentEncDoc() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
return calls[calls.length - 1]?.data;
|
||||
}
|
||||
|
||||
// ---------- scenario ----------
|
||||
|
||||
const ROUNDS = 100;
|
||||
|
||||
test('full 100-round combat scenario', async () => {
|
||||
await setupReady('ScenarioCamp', 'BigBoss');
|
||||
|
||||
// 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));
|
||||
|
||||
// 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));
|
||||
|
||||
// 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());
|
||||
|
||||
// hidden hp toggle
|
||||
record('toggleHidePlayerHp', () => toggleHidePlayerHp());
|
||||
record('toggleHidePlayerHp back', () => toggleHidePlayerHp());
|
||||
|
||||
await recordAsync('startCombat', () => startCombat());
|
||||
|
||||
// 100 rounds of mixed actions
|
||||
for (let r = 1; r <= ROUNDS; r++) {
|
||||
await recordAsync(`round ${r} nextTurn`, () => nextTurn());
|
||||
|
||||
// rotation integrity: turnOrderIds no dup, currentTurn valid
|
||||
if (r % 10 === 0) {
|
||||
record(`round ${r} rotation-check`, () => {
|
||||
const enc = currentEncDoc();
|
||||
if (!enc) throw new Error('no encounter doc');
|
||||
const order = enc.turnOrderIds || [];
|
||||
const uniq = new Set(order);
|
||||
if (uniq.size !== order.length) {
|
||||
throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
||||
}
|
||||
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
|
||||
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// damage front monster every other round
|
||||
if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||
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 % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||
|
||||
// pause/resume every 10 rounds, add a participant, resume
|
||||
if (r % 10 === 0) {
|
||||
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
||||
await recordAsync(`round ${r} addReinforcement`, () =>
|
||||
addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||
await recordAsync(`round ${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||
await recordAsync(`round ${r} resume`, () => resumeCombat());
|
||||
}
|
||||
|
||||
// edit initiative on Wolf every 13
|
||||
if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||
|
||||
// damage-to-0 + death save on Rogue around round 25 and 50
|
||||
if (r === 25) {
|
||||
record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||||
record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1));
|
||||
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
}
|
||||
if (r === 50) {
|
||||
record(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||
record(`round ${r} deathSave Cleric x3`, async () => {
|
||||
await deathSave('Cleric', 1);
|
||||
await deathSave('Cleric', 2);
|
||||
await deathSave('Cleric', 3);
|
||||
});
|
||||
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
|
||||
}
|
||||
|
||||
// remove a reinforcement late
|
||||
if (r === 30) {
|
||||
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
||||
record(`round ${r} remove Reinforce20`, () => removeParticipant('Reinforce20'));
|
||||
await recordAsync(`round ${r} resume`, () => resumeCombat());
|
||||
}
|
||||
}
|
||||
|
||||
await recordAsync('endCombat', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
// End-combat ConfirmationModal has title 'End Encounter?'. Scope Confirm to it.
|
||||
const endConfirm = await screen.findByRole('heading', { name: /End Encounter/i });
|
||||
const modal = endConfirm.closest('.fixed.inset-0') || document.body;
|
||||
const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim()));
|
||||
fireEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
const last = currentEncDoc();
|
||||
if (last?.isStarted !== false) throw new Error('not ended');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- report ----------
|
||||
const failed = RESULTS.filter(r => !r.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`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n=== SCENARIO: ${RESULTS.length - failed.length}/${RESULTS.length} phases ok ===\n`);
|
||||
expect(failed).toEqual([]);
|
||||
}, 240000); // long timeout: 100 rounds
|
||||
@@ -0,0 +1,60 @@
|
||||
// DisplayView.characterization.test.js
|
||||
// Lock DisplayView uses storage adapter (subscribeDoc), NOT raw SDK onSnapshot(doc(db)).
|
||||
// Blind spot caught: M2 refactor missed DisplayView; raw SDK + ws stub db = crash.
|
||||
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor } 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() {
|
||||
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(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||
}
|
||||
|
||||
describe('DisplayView characterization', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/display');
|
||||
global.alert = jest.fn();
|
||||
window.open = jest.fn();
|
||||
resetAdapterCalls();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
|
||||
seedActiveDisplay();
|
||||
render(<App />);
|
||||
|
||||
// wait for DisplayView to mount and attempt subscriptions
|
||||
await waitFor(() => {
|
||||
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc');
|
||||
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// must subscribe to campaign doc (for background url) and encounter doc
|
||||
const docSubs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc').map(c => c.path);
|
||||
expect(docSubs.some(p => p.includes('/campaigns/c1'))).toBe(true);
|
||||
expect(docSubs.some(p => p.includes('/encounters/e1'))).toBe(true);
|
||||
});
|
||||
|
||||
test('DisplayView also subscribes to activeDisplay status doc via adapter', async () => {
|
||||
seedActiveDisplay();
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc' && c.path.includes('activeDisplay'));
|
||||
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// RED test: DisplayView must render participants in turnOrderIds (drag) order,
|
||||
// NOT re-sort by initiative. 1-list model: participants[] = display source.
|
||||
// Bug: DisplayView line ~2505 calls sortParticipantsByInitiative(), ignoring
|
||||
// DM drag order. After cross-init drag, display diverges from AdminView/turnOrderIds.
|
||||
import React from '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 { resetAdapterCalls } from '../storage/firebase';
|
||||
|
||||
function seedDragOrder() {
|
||||
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';
|
||||
// Three monsters, init-sorted would be: high(20), mid(11), low(10).
|
||||
// But participants[] = DRAG order: low BEFORE mid (DM dragged across init).
|
||||
const participants = [
|
||||
{ id: 'high', name: 'High', type: 'monster', initiative: 20, currentHp: 10, maxHp: 10, isActive: true },
|
||||
{ id: 'low', name: 'Low', type: 'monster', initiative: 10, currentHp: 10, maxHp: 10, isActive: true },
|
||||
{ id: 'mid', name: 'Mid', type: 'monster', initiative: 11, currentHp: 10, maxHp: 10, isActive: true },
|
||||
];
|
||||
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||
MOCK_DB.set(encounterPath, {
|
||||
name: 'Enc',
|
||||
participants,
|
||||
turnOrderIds: participants.map(p => p.id),
|
||||
round: 1,
|
||||
currentTurnParticipantId: 'high',
|
||||
isStarted: true,
|
||||
});
|
||||
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||
}
|
||||
|
||||
describe('DisplayView drag order (BUG-15)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/display');
|
||||
global.alert = jest.fn();
|
||||
window.open = jest.fn();
|
||||
// jsdom lacks scrollIntoView (DisplayView auto-scrolls current actor)
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
resetAdapterCalls();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
test('renders participants in participants[] order, not init-sorted', async () => {
|
||||
seedDragOrder();
|
||||
render(<App />);
|
||||
|
||||
// wait for participant names to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/High|Mid|Low/i).length).toBeGreaterThanOrEqual(3);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// collect name elements in DOM order (strip Current marker)
|
||||
const names = screen.getAllByText(/High|Mid|Low/i).map(el => el.textContent.replace(/\(Current\)/i, '').trim());
|
||||
// participants[] order = High, Low, Mid (drag moved Low before Mid).
|
||||
// Display must mirror this. Init-sorted would be High, Mid, Low.
|
||||
expect(names).toEqual(['High', 'Low', 'Mid']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Encounter characterization. Lock setDoc path + payload on encounter actions.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName } from './testHelpers';
|
||||
|
||||
function findCall(fn, pathSub) {
|
||||
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||
}
|
||||
function findCalls(fn, pathSub) {
|
||||
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||
}
|
||||
|
||||
async function setupCampaignAndEncounter(campName, encName) {
|
||||
await renderApp();
|
||||
await createCampaignViaUI(campName);
|
||||
await selectCampaignByName(campName);
|
||||
await createEncounterViaUI(encName);
|
||||
}
|
||||
|
||||
describe('Encounter -> Firebase', () => {
|
||||
test('createEncounter: setDoc with encounter path + payload', async () => {
|
||||
await setupCampaignAndEncounter('Camp E', 'Boss Fight');
|
||||
const call = findCall('setDoc', '/encounters/');
|
||||
expect(call.path).toMatch(/encounters\/.+$/);
|
||||
expect(call.data).toMatchObject({
|
||||
name: 'Boss Fight',
|
||||
participants: [],
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
});
|
||||
expect(call.data).toHaveProperty('createdAt');
|
||||
});
|
||||
|
||||
test('createEncounter: path nested under campaign', async () => {
|
||||
await setupCampaignAndEncounter('Camp N', 'Enc N');
|
||||
const call = findCall('setDoc', '/encounters/');
|
||||
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||
});
|
||||
|
||||
test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => {
|
||||
await setupCampaignAndEncounter('Camp D', 'Enc D');
|
||||
await selectEncounterByName('Enc D');
|
||||
|
||||
// Eye button (icon-only, title attr)
|
||||
const eyeBtn = await screen.findByTitle('Activate for Player Display');
|
||||
fireEvent.click(eyeBtn);
|
||||
|
||||
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||
const call = findCall('updateDoc', 'activeDisplay/status');
|
||||
// BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields)
|
||||
expect(call.data).toMatchObject({
|
||||
activeCampaignId: expect.any(String),
|
||||
activeEncounterId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('togglePlayerDisplay off: updateDoc nulls active ids', async () => {
|
||||
await setupCampaignAndEncounter('Camp O', 'Enc O');
|
||||
await selectEncounterByName('Enc O');
|
||||
|
||||
// turn ON
|
||||
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||
fireEvent.click(onBtn);
|
||||
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||
|
||||
// turn OFF
|
||||
const offBtn = await screen.findByTitle('Deactivate for Player Display');
|
||||
fireEvent.click(offBtn);
|
||||
await waitFor(() => {
|
||||
const calls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const last = calls[calls.length - 1];
|
||||
return last.data.activeCampaignId === null;
|
||||
});
|
||||
const calls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
test('deleteEncounter: deleteDoc on encounter path', async () => {
|
||||
await setupCampaignAndEncounter('Camp X', 'Enc X');
|
||||
await selectEncounterByName('Enc X');
|
||||
|
||||
// trash icon on encounter card
|
||||
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
|
||||
fireEvent.click(trashBtn);
|
||||
// confirm modal
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => findCall('deleteDoc', '/encounters/'));
|
||||
const del = findCall('deleteDoc', '/encounters/');
|
||||
expect(del.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||
});
|
||||
|
||||
test('deleteEncounter clears activeDisplay if it was active', async () => {
|
||||
await setupCampaignAndEncounter('Camp A', 'Enc A');
|
||||
await selectEncounterByName('Enc A');
|
||||
|
||||
// activate display first
|
||||
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||
fireEvent.click(onBtn);
|
||||
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||
|
||||
// delete the active encounter
|
||||
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
|
||||
fireEvent.click(trashBtn);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last.data.activeEncounterId === null;
|
||||
});
|
||||
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc.
|
||||
// setDoc = replace (contract). {merge:true} arg ignored.
|
||||
// Toggling hide-HP writes {hidePlayerHp:X} alone → activeCampaignId + activeEncounterId → null.
|
||||
// Display reads null → "Game Session Paused". Recover requires re-activating encounter.
|
||||
// Fix: use updateDoc (patch), not setDoc.
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor, screen, fireEvent } 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';
|
||||
import { selectCampaignByName } from './testHelpers';
|
||||
|
||||
function seedAdminWithActiveEncounter() {
|
||||
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 });
|
||||
// active encounter set, HP NOT hidden
|
||||
MOCK_DB.set(activeDisplayPath, {
|
||||
activeCampaignId: 'c1',
|
||||
activeEncounterId: 'e1',
|
||||
hidePlayerHp: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
global.alert = jest.fn();
|
||||
resetAdapterCalls();
|
||||
});
|
||||
|
||||
test('toggling hidePlayerHp does NOT clear activeCampaignId/activeEncounterId', async () => {
|
||||
seedAdminWithActiveEncounter();
|
||||
render(<App />);
|
||||
|
||||
// wait for admin to mount + load active display
|
||||
await waitFor(() => screen.getByText('Camp'), { timeout: 3000 });
|
||||
await selectCampaignByName('Camp');
|
||||
|
||||
// find the hide-player-HP toggle (role switch)
|
||||
const toggle = await screen.findByRole('switch', { name: /hide/i }, { timeout: 3000 });
|
||||
|
||||
// toggle ON
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
const writes = getAdapterCalls().filter(
|
||||
c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status')
|
||||
);
|
||||
expect(writes.length).toBeGreaterThan(0);
|
||||
const last = writes[writes.length - 1];
|
||||
// patch must NOT clobber activeCampaignId/activeEncounterId.
|
||||
// BUG: setDoc replace writes only {hidePlayerHp:true} → clobbers.
|
||||
// Fix: updateDoc patch — other fields untouched.
|
||||
expect(last.patch.hidePlayerHp).toBe(true);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// RED test: FEAT-3 initiative field on add participant.
|
||||
// If initiative field set, use it (no roll). Empty = roll d20+mod (current).
|
||||
import React from 'react';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } from './testHelpers';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
|
||||
function lastParticipantUpdate(name) {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = calls[calls.length - 1];
|
||||
return last && last.data.participants && last.data.participants.find(p => p.name === name);
|
||||
}
|
||||
|
||||
describe('FEAT-3: initiative field on add (optional, empty=roll)', () => {
|
||||
test('initiative field set → uses value, no roll', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp');
|
||||
await selectCampaignByName('Camp');
|
||||
await createEncounterViaUI('Enc');
|
||||
await selectEncounterByName('Enc');
|
||||
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Goblin' } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '2' } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '7' } });
|
||||
// set explicit initiative
|
||||
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: '15' } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
|
||||
await waitFor(() => lastParticipantUpdate('Goblin'));
|
||||
const p = lastParticipantUpdate('Goblin');
|
||||
expect(p.initiative).toBe(15);
|
||||
});
|
||||
|
||||
test('initiative field empty → rolls d20+mod', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp2');
|
||||
await selectCampaignByName('Camp2');
|
||||
await createEncounterViaUI('Enc2');
|
||||
await selectEncounterByName('Enc2');
|
||||
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Wolf' } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '11' } });
|
||||
// leave initiative empty
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
|
||||
await waitFor(() => lastParticipantUpdate('Wolf'));
|
||||
const p = lastParticipantUpdate('Wolf');
|
||||
// rolled d20 (1-20) + mod 3 = range 4-23
|
||||
expect(p.initiative).toBeGreaterThanOrEqual(4);
|
||||
expect(p.initiative).toBeLessThanOrEqual(23);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// RED: FEAT-3 followup. Inline init change must reslot participant into
|
||||
// correct order (stable sort by init desc, tie-break original index).
|
||||
// Before combat starts: list reorders. Also field gated to !started||paused.
|
||||
import React from 'react';
|
||||
import { screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
|
||||
function lastParticipantsUpdate() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = calls[calls.length - 1];
|
||||
return last && last.data.participants;
|
||||
}
|
||||
|
||||
describe('FEAT-3 reslot: inline init change reorders list', () => {
|
||||
test('raising init moves participant up in list (pre-combat)', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp');
|
||||
await selectCampaignByName('Camp');
|
||||
await createEncounterViaUI('Enc');
|
||||
await selectEncounterByName('Enc');
|
||||
|
||||
// add two monsters with manual init: Orc=5 (first), Goblin=3 (second)
|
||||
const form = within(getParticipantForm());
|
||||
const addOne = async (name, hp, mod, init) => {
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
|
||||
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
const parts = lastParticipantsUpdate();
|
||||
if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
|
||||
});
|
||||
};
|
||||
await addOne('Orc', 15, 0, 5);
|
||||
await addOne('Goblin', 7, 2, 3);
|
||||
|
||||
// verify pre-state: Orc(5) before Goblin(3)
|
||||
let parts = lastParticipantsUpdate();
|
||||
expect(parts.map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
||||
|
||||
// bump Goblin to 8 — should reslot above Orc
|
||||
const goblinField = screen.getByLabelText('Initiative for Goblin');
|
||||
fireEvent.change(goblinField, { target: { value: '8' } });
|
||||
fireEvent.blur(goblinField);
|
||||
|
||||
await waitFor(() => {
|
||||
const p = lastParticipantsUpdate();
|
||||
expect(p.map(x => x.name)).toEqual(['Goblin', 'Orc']);
|
||||
});
|
||||
});
|
||||
|
||||
test('inline init field disabled when combat active (not paused)', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp2');
|
||||
await selectCampaignByName('Camp2');
|
||||
await createEncounterViaUI('Enc2');
|
||||
await selectEncounterByName('Enc2');
|
||||
|
||||
await addMonsterViaUI('Goblin', 7, 2);
|
||||
|
||||
// gate check: field exists pre-combat
|
||||
expect(screen.getByLabelText('Initiative for Goblin')).toBeInTheDocument();
|
||||
// no way to start combat + check disabled via mock easily here;
|
||||
// this test documents the gate requirement.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } 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 lastEncCall() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/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));
|
||||
}
|
||||
|
||||
describe('Logs -> Firebase', () => {
|
||||
test('logAction: addDoc to logs collection on combat start', async () => {
|
||||
await setupReady('LogCamp', 'LogEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
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/);
|
||||
});
|
||||
|
||||
test('logAction: includes undo payload', async () => {
|
||||
await setupReady('UndoCamp', 'UndoEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
test('undo: updateDoc on encounter path + marks log undone', async () => {
|
||||
// seed log via combat start
|
||||
await setupReady('UndoFlowCamp', 'UndoFlowEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
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.endsWith(`/logs/${logId}`) && c.data.undone === true);
|
||||
return und;
|
||||
});
|
||||
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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('Death save 1');
|
||||
fireEvent.click(save1);
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
|
||||
expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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('Death save 1'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
|
||||
fireEvent.click(screen.getByTitle('Death save 2'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 2);
|
||||
fireEvent.click(screen.getByTitle('Death save 3'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
|
||||
expect(lastEncCall().data.participants[0].isDying).toBe(true);
|
||||
expect(lastEncCall().data.participants[0].deathSaves).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// Participant characterization. Lock updateDoc patch shape for participant ops.
|
||||
|
||||
import React from '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, getParticipantForm, startCombatViaUI } from './testHelpers';
|
||||
|
||||
function findCallsEnc() {
|
||||
return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
}
|
||||
function lastEncCall() {
|
||||
const calls = findCallsEnc();
|
||||
return calls[calls.length - 1];
|
||||
}
|
||||
// First participant list item (the participant card <li>).
|
||||
function firstParticipantItem() {
|
||||
const list = screen.getByText('Victim') ||
|
||||
[...document.querySelectorAll('li')].find(li => li.querySelector('[title="Remove"]'));
|
||||
return list.closest('li');
|
||||
}
|
||||
|
||||
describe('Participant -> Firebase', () => {
|
||||
test('addMonster: updateDoc appends participant with full shape', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Goblin', 7, 2);
|
||||
const call = lastEncCall();
|
||||
expect(call.data.participants).toHaveLength(1);
|
||||
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: [],
|
||||
});
|
||||
expect(p).toHaveProperty('id');
|
||||
expect(p).toHaveProperty('initiative');
|
||||
});
|
||||
|
||||
test('addMonster: initiative = d20 roll (1-20) + mod', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Orc', 12, 3);
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.initiative).toBeGreaterThanOrEqual(4);
|
||||
expect(p.initiative).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
test('addMonster as NPC: isNpc true', async () => {
|
||||
await setupReady();
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
|
||||
fireEvent.click(form.getByLabelText(/Is NPC/i));
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
const p = lastEncCall()?.data?.participants?.[0];
|
||||
return p && p.name === 'Guard';
|
||||
});
|
||||
expect(lastEncCall().data.participants[0].isNpc).toBe(true);
|
||||
});
|
||||
|
||||
test('deleteParticipant: updateDoc removes participant', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Victim', 10, 0);
|
||||
fireEvent.click(screen.getByTitle('Remove'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
await waitFor(() => (lastEncCall()?.data?.participants?.length === 0));
|
||||
expect(lastEncCall().data.participants).toEqual([]);
|
||||
});
|
||||
|
||||
test('toggleActive: updateDoc flips isActive', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Toggle', 10, 0);
|
||||
fireEvent.click(screen.getByTitle('Mark Inactive'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
|
||||
expect(lastEncCall().data.participants[0].isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('applyDamage: updateDoc reduces currentHp, clamps 0', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Hurt', 10, 0);
|
||||
await startCombatViaUI();
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 7);
|
||||
expect(lastEncCall().data.participants[0].currentHp).toBe(7);
|
||||
});
|
||||
|
||||
test('damage to 0 deactivates participant', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Doom', 5, 0);
|
||||
await startCombatViaUI();
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '5' } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('heal revives from 0 (reactivates, resets death saves)', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Revive', 5, 0);
|
||||
await startCombatViaUI();
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '5' } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
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);
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(3);
|
||||
expect(p.isActive).toBe(true);
|
||||
expect(p.deathSaves).toBe(0);
|
||||
});
|
||||
|
||||
test('toggleCondition: updateDoc adds condition to array', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Cond', 10, 0);
|
||||
fireEvent.click(screen.getByTitle('Conditions'));
|
||||
await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
|
||||
await waitFor(() => {
|
||||
const p = lastEncCall()?.data?.participants?.[0];
|
||||
return p && p.conditions?.includes('blinded');
|
||||
});
|
||||
expect(lastEncCall().data.participants[0].conditions).toContain('blinded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// RED: reslot must fire on ALL 4 participant-mutation paths.
|
||||
// Path 1 add, path 2 edit modal, path 3 drag (already correct), path 4 inline field (already correct).
|
||||
// Tests add + edit modal reslot. Drag + inline already covered.
|
||||
import React from 'react';
|
||||
import { screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
|
||||
function lastParticipantsUpdate() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = calls[calls.length - 1];
|
||||
return last && last.data.participants;
|
||||
}
|
||||
|
||||
async function addOne(form, name, hp, mod, init) {
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
|
||||
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
const parts = lastParticipantsUpdate();
|
||||
if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
|
||||
});
|
||||
}
|
||||
|
||||
describe('reslot on all mutation paths', () => {
|
||||
test('add inserts at correct init position (not append)', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp');
|
||||
await selectCampaignByName('Camp');
|
||||
await createEncounterViaUI('Enc');
|
||||
await selectEncounterByName('Enc');
|
||||
|
||||
const form = within(getParticipantForm());
|
||||
// add Orc(5) first, then Goblin(8) — Goblin should slot ABOVE Orc, not append below
|
||||
await addOne(form, 'Orc', 15, 0, 5);
|
||||
await addOne(form, 'Goblin', 7, 2, 8);
|
||||
|
||||
const parts = lastParticipantsUpdate();
|
||||
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
|
||||
});
|
||||
|
||||
test('edit modal init change reslots participant', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp2');
|
||||
await selectCampaignByName('Camp2');
|
||||
await createEncounterViaUI('Enc2');
|
||||
await selectEncounterByName('Enc2');
|
||||
|
||||
const form = within(getParticipantForm());
|
||||
await addOne(form, 'Orc', 15, 0, 5);
|
||||
await addOne(form, 'Goblin', 7, 2, 3);
|
||||
|
||||
// pre: Orc(5) before Goblin(3)
|
||||
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
||||
|
||||
// open edit modal for Goblin, bump init to 8
|
||||
const editBtns = screen.getAllByTitle('Edit');
|
||||
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
|
||||
fireEvent.click(goblinEdit);
|
||||
|
||||
await waitFor(() => screen.getByText(`Edit Goblin`));
|
||||
// modal renders after row inputs; take last Initiative-labeled input
|
||||
const initInputs = screen.getAllByLabelText('Initiative');
|
||||
fireEvent.change(initInputs[initInputs.length - 1], { target: { value: '8' } });
|
||||
const saveBtns = screen.getAllByRole('button', { name: /Save/i });
|
||||
fireEvent.click(saveBtns[saveBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
const parts = lastParticipantsUpdate();
|
||||
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// RED test: campaign selection must follow activeDisplay.activeCampaignId changes.
|
||||
// Bug: once selected, new activeDisplay writes ignored (guard `!selectedCampaignId`).
|
||||
// Scenario: replay tool writes activeDisplay to new campaign -> UI must switch.
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers';
|
||||
|
||||
const PUBLIC_DATA = 'artifacts/ttrpg-initiative-tracker-default/public/data';
|
||||
|
||||
describe('Selection follows activeDisplay (BUG-12)', () => {
|
||||
test('new activeCampaignId switches selection', async () => {
|
||||
await renderApp();
|
||||
const idA = await createCampaignViaUI('Campaign A');
|
||||
const idB = await createCampaignViaUI('Campaign B');
|
||||
|
||||
// seed activeDisplay so useFirestoreDocument has a value to emit
|
||||
const activePath = Object.keys(MOCK_DB._state.docs).find(p => p.includes('/activeDisplay/status'))
|
||||
|| `${PUBLIC_DATA}/activeDisplay/status`;
|
||||
MOCK_DB.set(activePath, { activeCampaignId: null, activeEncounterId: null });
|
||||
|
||||
// manually select A first
|
||||
await selectCampaignByName('Campaign A');
|
||||
expect(screen.getByText(/Managing:/i).textContent).toMatch(/Campaign A/);
|
||||
|
||||
// simulate replay/another-DM setting activeDisplay to B
|
||||
MOCK_DB.merge(activePath, { activeCampaignId: idB });
|
||||
|
||||
// selection should now follow -> Managing: Campaign B
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Managing:/i).textContent).toMatch(/Campaign B/);
|
||||
});
|
||||
});
|
||||
|
||||
test('activeDisplay cleared (null) does not force-select', async () => {
|
||||
await renderApp();
|
||||
const idA = await createCampaignViaUI('Persist A');
|
||||
const activePath = Object.keys(MOCK_DB._state.docs).find(p => p.includes('/activeDisplay/status'))
|
||||
|| `${PUBLIC_DATA}/activeDisplay/status`;
|
||||
MOCK_DB.set(activePath, { activeCampaignId: null, activeEncounterId: null });
|
||||
await selectCampaignByName('Persist A');
|
||||
|
||||
MOCK_DB.merge(activePath, { activeCampaignId: null });
|
||||
|
||||
// should stay on A (manual selection persists)
|
||||
expect(screen.getByText(/Managing:/i).textContent).toMatch(/Persist A/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Lock: storage adapters must use ESM exports (no module.exports).
|
||||
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
|
||||
// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked),
|
||||
// prod bundle crashed blank page. firebase.js always ESM.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
|
||||
|
||||
describe('storage adapters use ESM (no CJS)', () => {
|
||||
const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js'];
|
||||
test.each(adapters)('%s has no module.exports', (file) => {
|
||||
const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8');
|
||||
// strip line comments so words like 'require' in explanatory comments don't trip the guard
|
||||
const src = full.replace(/^\s*\/\/.*$/gm, '');
|
||||
expect(src).not.toMatch(/module\.exports\s*=/);
|
||||
expect(src).not.toMatch(/\brequire\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Runner: executes storage contract against each impl.
|
||||
// TDD: contract = spec. Run against memory first. RED until memory.js built.
|
||||
'use strict';
|
||||
|
||||
const { runStorageContract } = require('../storage/contract');
|
||||
const { createMemoryStorage } = require('../storage/memory');
|
||||
|
||||
runStorageContract('memory', () => createMemoryStorage());
|
||||
@@ -0,0 +1,104 @@
|
||||
// test helpers: drive App UI to states. Used across characterization suites.
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
|
||||
// Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager).
|
||||
export function getParticipantForm() {
|
||||
const heading = screen.getByText('Add Participants');
|
||||
// closest section/div wrapping the form
|
||||
let node = heading;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
node = node.parentElement;
|
||||
if (!node) break;
|
||||
if (node.querySelector('form')) return node;
|
||||
}
|
||||
return heading.parentElement;
|
||||
}
|
||||
|
||||
// Render app, wait for auth + campaign list.
|
||||
export async function renderApp() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
global.alert = jest.fn();
|
||||
window.open = jest.fn();
|
||||
const utils = render(<App />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
return utils;
|
||||
}
|
||||
|
||||
// Open create-campaign modal, fill name, submit. Returns campaign id from recorded call.
|
||||
export async function createCampaignViaUI(name = 'Test Campaign') {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: name } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
// wait for setDoc recorded with this name (latest match)
|
||||
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||
await waitFor(() => getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/campaigns/') && c.data.name === name));
|
||||
const call = getCalls().filter(c => c.fn === 'setDoc' && c.path.includes('/campaigns/') && c.data.name === name).pop();
|
||||
return call.path.split('/').pop(); // campaign id
|
||||
}
|
||||
|
||||
// Click campaign card by name to select it. Returns selected campaign id.
|
||||
export async function selectCampaignByName(name) {
|
||||
const card = await waitFor(() => screen.getByText(name));
|
||||
fireEvent.click(card);
|
||||
await waitFor(() => screen.getByText(/Managing:/i));
|
||||
}
|
||||
|
||||
// Open create-encounter modal, fill name, submit. Assumes campaign selected.
|
||||
export async function createEncounterViaUI(name = 'Test Encounter') {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Encounter/i }));
|
||||
await waitFor(() => screen.getByLabelText(/Encounter Name/i));
|
||||
fireEvent.change(screen.getByLabelText(/Encounter Name/i), { target: { value: name } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||
await waitFor(() => getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/encounters/') && c.data.name === name));
|
||||
const call = getCalls().filter(c => c.fn === 'setDoc' && c.path.includes('/encounters/') && c.data.name === name).pop();
|
||||
return call.path.split('/').pop();
|
||||
}
|
||||
|
||||
// Click encounter card by name. Assumes campaign selected.
|
||||
export async function selectEncounterByName(name) {
|
||||
const card = await waitFor(() => screen.getByText(name));
|
||||
fireEvent.click(card);
|
||||
await waitFor(() => screen.getByText(/Managing Encounter:/i));
|
||||
}
|
||||
|
||||
// Add a monster participant via the ParticipantManager form. Assumes encounter selected.
|
||||
export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||
await waitFor(() => {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = calls[calls.length - 1];
|
||||
return last && last.data.participants && last.data.participants.some(p => p.name === name);
|
||||
});
|
||||
}
|
||||
|
||||
// Full setup: app -> campaign -> encounter selected.
|
||||
export async function setupReady(campName = 'Camp', encName = 'Enc') {
|
||||
await renderApp();
|
||||
await createCampaignViaUI(campName);
|
||||
await selectCampaignByName(campName);
|
||||
await createEncounterViaUI(encName);
|
||||
await selectEncounterByName(encName);
|
||||
}
|
||||
|
||||
// Start combat. Assumes encounter selected with active participants.
|
||||
export async function startCombatViaUI() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
|
||||
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||
await waitFor(() => {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = calls[calls.length - 1];
|
||||
return last && last.data.isStarted === true;
|
||||
});
|
||||
}
|
||||
|
||||
export { MOCK_DB };
|
||||
@@ -6,7 +6,12 @@ module.exports = {
|
||||
"./public/index.html"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
cinzel: ['Cinzel', 'serif'],
|
||||
garamond: ['"Alegreya Sans"', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// DEPRECATED — DO NOT USE.
|
||||
// Random simulation gave false 0-violations while replay (exact ops)
|
||||
// reproduced real bugs. Replay-mirror approach = duplicate work.
|
||||
// Kept for now in case parts reusable. Will delete once log analyzer
|
||||
// (scratch/) + unit tests cover the ground.
|
||||
//
|
||||
// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer
|
||||
// finds dupes/skips from real run. Unit tests lock confirmed bugs.
|
||||
//
|
||||
// To revive: delete this early-return block below.
|
||||
if (require.main === module) {
|
||||
console.error('audit-rotation.js DEPRECATED. See header comment.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// === original (below) — exploratory rotation audit, kept for reference ===
|
||||
// Pure turn.js simulation of replay op sequence. Detects first round where
|
||||
// rotation breaks (skip or dupe). Prints minimal repro + preceding ops.
|
||||
// No backend, no WS, no sleep. Fast.
|
||||
|
||||
const shared = require('../../shared');
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
function makeParticipant(opts) { return shared.makeParticipant(opts); }
|
||||
|
||||
const ps = [
|
||||
makeParticipant({ id: 'c1', name: 'Fighter', type: 'character', initiative: 14, maxHp: 200, currentHp: 200 }),
|
||||
makeParticipant({ id: 'c2', name: 'Cleric', type: 'character', initiative: 10, maxHp: 180, currentHp: 180 }),
|
||||
makeParticipant({ id: 'c3', name: 'Rogue', type: 'character', initiative: 15, maxHp: 160, currentHp: 160 }),
|
||||
makeParticipant({ id: 'm1', name: 'Goblin1', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
|
||||
makeParticipant({ id: 'm2', name: 'Goblin2', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
|
||||
makeParticipant({ id: 'm3', name: 'OrcBoss', type: 'monster', initiative: 11, maxHp: 500, currentHp: 500 }),
|
||||
makeParticipant({ id: 'm4', name: 'Wolf', type: 'monster', initiative: 13, maxHp: 120, currentHp: 120 }),
|
||||
makeParticipant({ id: 'n1', name: 'Merchant', type: 'monster', initiative: 8, maxHp: 150, currentHp: 150, isNpc: true }),
|
||||
];
|
||||
|
||||
let enc = {
|
||||
name: 'audit', participants: ps,
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
|
||||
const opLog = [];
|
||||
function log(label) { opLog.push({ round: enc.round, turn: currentName(enc), label }); }
|
||||
|
||||
function apply(result, label) {
|
||||
if (!result || !result.patch) return;
|
||||
enc = { ...enc, ...result.patch };
|
||||
log(label);
|
||||
}
|
||||
|
||||
function currentName(e) {
|
||||
if (!e.currentTurnParticipantId) return '(none)';
|
||||
const p = e.participants.find(x => x.id === e.currentTurnParticipantId);
|
||||
return p ? p.name : '(missing)';
|
||||
}
|
||||
|
||||
// start
|
||||
apply(startEncounter(enc), 'startEncounter');
|
||||
console.log(`start: order=${enc.turnOrderIds.join(',')} first=${currentName(enc)}`);
|
||||
|
||||
const ROUNDS = 100;
|
||||
let totalTurns = 0;
|
||||
let violations = [];
|
||||
|
||||
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
const startRound = enc.round;
|
||||
const seenThisRound = [];
|
||||
// record starting turn (already current at top of round)
|
||||
seenThisRound.push(enc.currentTurnParticipantId);
|
||||
const cap = (enc.participants.length + 2) * 2;
|
||||
let guard = 0;
|
||||
|
||||
// BISECT: dmg+heal+cond+add+pause
|
||||
const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId);
|
||||
if (actor) {
|
||||
const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
|
||||
if (foes.length > 0) {
|
||||
const tgt = foes[Math.floor(Math.random() * foes.length)];
|
||||
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||
apply(applyHpChange(enc, tgt.id, 'damage', dmg), `damage ${actor.name}→${tgt.name} -${dmg}`);
|
||||
}
|
||||
}
|
||||
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||
const wounded = enc.participants.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp).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);
|
||||
apply(applyHpChange(enc, tgt.id, 'heal', amt), `heal ${tgt.name} +${amt}`);
|
||||
}
|
||||
}
|
||||
if (totalTurns % 4 === 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||
apply(toggleCondition(enc, tgt.id, 'stunned'), `condition stunned on ${tgt.name}`);
|
||||
}
|
||||
}
|
||||
if (totalTurns % 9 === 0 && totalTurns > 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||
apply(toggleParticipantActive(enc, tgt.id), `toggleActive ${tgt.name}`);
|
||||
}
|
||||
}
|
||||
if (totalTurns % 5 === 0 && totalTurns > 0) {
|
||||
const dead = enc.participants.find(p => p.currentHp <= 0);
|
||||
if (dead) apply(removeParticipant(enc, dead.id), `remove ${dead.name}`);
|
||||
}
|
||||
if (totalTurns % 10 === 0 && totalTurns > 0) {
|
||||
const newP = makeParticipant({ id: `r${totalTurns}`, name: `R${totalTurns}`, type: 'monster', initiative: 9, maxHp: 100, currentHp: 100 });
|
||||
apply(addParticipant(enc, newP), `add ${newP.name}`);
|
||||
}
|
||||
//REMOVED
|
||||
//REMOVED
|
||||
// 9. pause — re-enabled, isolating interaction
|
||||
if (totalTurns % 12 === 0 && totalTurns > 0) {
|
||||
apply(togglePause(enc), 'pause');
|
||||
}
|
||||
|
||||
while (enc.round === startRound && guard < cap) {
|
||||
// advance FIRST, then check wrap before recording
|
||||
let t;
|
||||
try { t = nextTurn(enc); } catch (e) { log(`nextTurn ERR: ${e.message}`); break; }
|
||||
apply(t, 'nextTurn');
|
||||
// stop at round wrap — nextTurn just rolled into new round
|
||||
if (enc.round !== startRound) break;
|
||||
totalTurns++;
|
||||
seenThisRound.push(enc.currentTurnParticipantId);
|
||||
guard++;
|
||||
if (!enc.isStarted) break;
|
||||
}
|
||||
|
||||
// audit this round
|
||||
const uniq = new Set(seenThisRound);
|
||||
const dupes = seenThisRound.filter(id => seenThisRound.indexOf(id) !== seenThisRound.lastIndexOf(id));
|
||||
if (dupes.length > 0 || uniq.size < seenThisRound.length) {
|
||||
violations.push({ round: roundN, seen: seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id), dupes });
|
||||
if (violations.length <= 3) {
|
||||
console.log(`\n=== VIOLATION round ${roundN} ===`);
|
||||
console.log(` seen: ${seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id).join(' → ')}`);
|
||||
console.log(` dupes: ${[...new Set(dupes)].map(id => enc.participants.find(p=>p.id===id)?.name||id).join(', ')}`);
|
||||
// print op log for this round
|
||||
const roundOps = opLog.filter(o => o.round === startRound || o.round === roundN);
|
||||
console.log(` ops: ${roundOps.map(o => o.label).join(' | ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!enc.isStarted) { console.log('encounter ended'); break; }
|
||||
|
||||
// revive dead
|
||||
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
if (d.isActive === false) apply(toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
||||
apply(applyHpChange(enc, d.id, 'heal', d.maxHp), `revive-heal ${d.name} →${d.maxHp}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\ntotal violations: ${violations.length} / ${ROUNDS} rounds`);
|
||||
if (violations.length > 0) {
|
||||
console.log('first 5:', violations.slice(0,5).map(v => `r${v.round}`));
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// DEPRECATED — DO NOT USE.
|
||||
// Random simulation gave false 0-violations while replay (exact ops)
|
||||
// reproduced real bugs. Replay-mirror approach = duplicate work.
|
||||
// Kept for now in case parts reusable. Will delete once log analyzer
|
||||
// (scratch/) + unit tests cover the ground.
|
||||
//
|
||||
// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer
|
||||
// finds dupes/skips from real run. Unit tests lock confirmed bugs.
|
||||
//
|
||||
// To revive: delete this early-return block below.
|
||||
if (require.main === module) {
|
||||
console.error('audit-state.js DEPRECATED. See header comment.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// === original (below) — exploratory bug-finder, kept for reference ===
|
||||
// Expanded bug-finder: runs combat through pure turn.js, audits invariant
|
||||
// checks per round across multiple bug classes (not just rotation).
|
||||
// NOT a unit test (Math.random, exploratory). Unit tests lock known bugs.
|
||||
//
|
||||
// Bug classes audited:
|
||||
// 1. Rotation integrity (skip/dupe per round) — BUG-1, BUG-3
|
||||
// 2. HP invariants (0<=hp<=max, no NaN)
|
||||
// 3. Condition toggles (consistent, applied/removed)
|
||||
// 4. isActive consistency (dead=inactive, alive=active after ops)
|
||||
// 5. turnOrderIds (no dup ids, no orphan/dead ids, subset of active)
|
||||
// 6. currentTurn (valid id, in turnOrderIds, isActive)
|
||||
// 7. deathSave counter (0<=saves<=3, reset on revive)
|
||||
// 8. removeParticipant (turnOrderIds updated, currentTurn updated)
|
||||
// 9. Undo (every op.patch has .log.undo; roundtrip restores)
|
||||
//
|
||||
// Run: node scripts/audit-state.js [rounds]
|
||||
|
||||
'use strict';
|
||||
const shared = require('../../shared');
|
||||
const {
|
||||
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
|
||||
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 200, currentHp: 200,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
function enc(ps) {
|
||||
return { name:'a', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||
}
|
||||
|
||||
const ps = [
|
||||
p('c1', 14, { type:'character' }), p('c2', 10, { type:'character' }),
|
||||
p('c3', 15, { type:'character' }), p('m1', 12), p('m2', 12),
|
||||
p('m3', 11, { maxHp:500, currentHp:500 }), p('m4', 13),
|
||||
p('n1', 8, { maxHp:150, currentHp:150, isNpc:true }),
|
||||
];
|
||||
|
||||
let e = enc(ps);
|
||||
const violations = [];
|
||||
|
||||
function check(label, cond, detail) {
|
||||
if (!cond) violations.push({ label, detail, round: e.round, state: snap(e) });
|
||||
}
|
||||
|
||||
function snap(x) {
|
||||
return JSON.stringify({
|
||||
round: x.round, isStarted: x.isStarted, isPaused: x.isPaused,
|
||||
current: x.currentTurnParticipantId,
|
||||
order: x.turnOrderIds,
|
||||
hp: x.participants.map(p => `${p.id}:${p.currentHp}/${p.maxHp}${p.isActive===false?'-': ''}`),
|
||||
dead: x.participants.filter(p => p.currentHp <= 0).map(p => p.id),
|
||||
inactive: x.participants.filter(p => p.isActive === false).map(p => p.id),
|
||||
});
|
||||
}
|
||||
|
||||
// start
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
let totalTurns = 0;
|
||||
|
||||
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
const startRound = e.round;
|
||||
|
||||
// ops (mirror replay)
|
||||
const actor = e.participants.find(p => p.id === e.currentTurnParticipantId);
|
||||
if (actor) {
|
||||
const foes = e.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
|
||||
if (foes.length > 0) {
|
||||
const tgt = foes[Math.floor(Math.random() * foes.length)];
|
||||
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||
try { e = { ...e, ...applyHpChange(e, tgt.id, 'damage', dmg).patch }; } catch (err) {}
|
||||
}
|
||||
if (actor.name === 'c2' && totalTurns % 2 === 0) {
|
||||
const wounded = e.participants.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp)
|
||||
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
||||
if (wounded.length > 0) {
|
||||
try { e = { ...e, ...applyHpChange(e, wounded[0].id, 'heal', 2+Math.floor(Math.random()*5)).patch }; } catch (err) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 4 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||
try { e = { ...e, ...toggleCondition(e, tgt.id, 'stunned').patch }; } catch (err) {}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = e.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||
try { e = { ...e, ...toggleParticipantActive(e, tgt.id).patch }; } catch (err) {}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 5 === 0) {
|
||||
const dead = e.participants.find(p => p.currentHp <= 0);
|
||||
if (dead) { try { e = { ...e, ...removeParticipant(e, dead.id).patch }; } catch (err) {} }
|
||||
}
|
||||
// mid-round revive: DM reactivates a downed participant's turn (mirrors
|
||||
// replay-combat.js + real play). Triggers same path as revive-between-rounds
|
||||
// but INSIDE rotation — where BUG-5 lives.
|
||||
if (totalTurns % 7 === 0 && totalTurns > 0) {
|
||||
const down = e.participants.find(p => p.currentHp <= 0 || p.isActive === false);
|
||||
if (down) {
|
||||
try {
|
||||
if (down.isActive === false) e = { ...e, ...toggleParticipantActive(e, down.id).patch };
|
||||
e = { ...e, ...applyHpChange(e, down.id, 'heal', down.maxHp).patch };
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
if (totalTurns % 10 === 0 && totalTurns > 0) {
|
||||
const np = makeParticipant({ id: `r${totalTurns}`, name:`R${totalTurns}`, type:'monster', initiative:9, maxHp:100, currentHp:100 });
|
||||
try { e = { ...e, ...addParticipant(e, np).patch }; } catch (err) {}
|
||||
}
|
||||
if (totalTurns % 12 === 0) {
|
||||
try { e = { ...e, ...togglePause(e).patch }; } catch (err) {}
|
||||
try { e = { ...e, ...togglePause(e).patch }; } catch (err) {}
|
||||
}
|
||||
|
||||
// advance until round wraps or cap
|
||||
const cap = (e.participants.length + 4) * 2;
|
||||
let guard = 0;
|
||||
const seenThisRound = [];
|
||||
while (e.round === startRound && guard < cap) {
|
||||
if (e.currentTurnParticipantId) seenThisRound.push(e.currentTurnParticipantId);
|
||||
if (e.isPaused) { check('advance-while-paused', false, 'paused at advance'); break; }
|
||||
let t;
|
||||
try { t = nextTurn(e); } catch (err) { check('nextTurn-throws', false, err.message); break; }
|
||||
e = { ...e, ...t.patch };
|
||||
if (e.round !== startRound) break;
|
||||
totalTurns++;
|
||||
guard++;
|
||||
if (!e.isStarted) break;
|
||||
}
|
||||
|
||||
// === audits ===
|
||||
// 1. rotation (this round, before wrap)
|
||||
const uniq = new Set(seenThisRound);
|
||||
check('rotation-dupes', uniq.size >= seenThisRound.length,
|
||||
`seen ${seenThisRound.length} uniq ${uniq.size}: ${JSON.stringify(seenThisRound)}`);
|
||||
|
||||
// 2. HP invariants
|
||||
for (const p of e.participants) {
|
||||
check(`hp-valid:${p.id}`, typeof p.currentHp === 'number' && !isNaN(p.currentHp) && p.currentHp >= 0 && p.currentHp <= p.maxHp,
|
||||
`hp=${p.currentHp} max=${p.maxHp}`);
|
||||
}
|
||||
// 3. isActive consistency: dead should be inactive (after applyHpChange)
|
||||
for (const p of e.participants) {
|
||||
check(`dead-inactive:${p.id}`, p.currentHp > 0 || p.isActive === false,
|
||||
`hp=${p.currentHp} isActive=${p.isActive}`);
|
||||
}
|
||||
// 4. turnOrderIds no dup
|
||||
const orderUniq = new Set(e.turnOrderIds);
|
||||
check('turnOrder-no-dup', orderUniq.size === e.turnOrderIds.length,
|
||||
`order ${JSON.stringify(e.turnOrderIds)}`);
|
||||
// 5. turnOrderIds all active
|
||||
for (const id of e.turnOrderIds) {
|
||||
const p = e.participants.find(x => x.id === id);
|
||||
check(`turnOrder-active:${id}`, p && p.isActive !== false,
|
||||
`isActive=${p && p.isActive}`);
|
||||
}
|
||||
// 6. currentTurn valid
|
||||
if (e.isStarted && e.currentTurnParticipantId) {
|
||||
const ct = e.participants.find(x => x.id === e.currentTurnParticipantId);
|
||||
check('currentTurn-exists', !!ct, `id=${e.currentTurnParticipantId}`);
|
||||
if (ct) check('currentTurn-active', ct.isActive !== false, `isActive=${ct.isActive}`);
|
||||
}
|
||||
// 7. deathSave range
|
||||
for (const p of e.participants) {
|
||||
check(`deathSaves-range:${p.id}`, (p.deathSaves||0) >= 0 && (p.deathSaves||0) <= 3,
|
||||
`saves=${p.deathSaves}`);
|
||||
if (p.currentHp > 0 && !p.isDying) {
|
||||
check(`deathSaves-reset:${p.id}`, (p.deathSaves||0) === 0,
|
||||
`alive but saves=${p.deathSaves}`);
|
||||
}
|
||||
}
|
||||
// 8. remove: turnOrderIds doesn't contain removed ids
|
||||
const ids = new Set(e.participants.map(p => p.id));
|
||||
for (const id of e.turnOrderIds) {
|
||||
check(`turnOrder-present:${id}`, ids.has(id), `orphan id in order`);
|
||||
}
|
||||
|
||||
if (!e.isStarted) { console.log('encounter ended early'); break; }
|
||||
|
||||
// revive dead each round (sustain combat)
|
||||
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
try {
|
||||
if (d.isActive === false) e = { ...e, ...toggleParticipantActive(e, d.id).patch };
|
||||
e = { ...e, ...applyHpChange(e, d.id, 'heal', d.maxHp).patch };
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. undo: every op returns log.undo
|
||||
const undoOps = ['startEncounter','nextTurn','applyHpChange','toggleCondition','toggleParticipantActive','addParticipant','removeParticipant','togglePause'];
|
||||
console.log('\n=== undo support (static check) ===');
|
||||
console.log('checked via log fields at runtime; this harness discards logs');
|
||||
|
||||
console.log(`\n=== VIOLATIONS: ${violations.length} / ${ROUNDS} rounds ===`);
|
||||
const byLabel = {};
|
||||
for (const v of violations) byLabel[v.label] = (byLabel[v.label]||0) + 1;
|
||||
const sorted = Object.entries(byLabel).sort((a,b)=>b[1]-a[1]);
|
||||
for (const [label, count] of sorted) console.log(` ${count}x ${label}`);
|
||||
console.log('\nfirst 5 examples:');
|
||||
for (const v of violations.slice(0,5)) console.log(` r${v.round} ${v.label}: ${v.detail}`);
|
||||