Compare commits
128 Commits
v1.0
...
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 | |||
| 8cf3a49a76 | |||
| dda3453d79 | |||
| d754f8657c | |||
| 9563ce7959 | |||
| 5f8602cd73 | |||
| 788e3cd1a2 | |||
| 99a38bb75a | |||
| 9f73dedcad | |||
| 893fe49ccb | |||
| 6adcd0f8e0 | |||
| d631545570 | |||
| ad11bbc648 | |||
| 785af983da | |||
| 40a798514d |
@@ -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,29 +1,68 @@
|
||||
# TTRPG Initiative Tracker
|
||||
# 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 or for players to view on their own devices.
|
||||
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 your game sessions.
|
||||
* **Character Management:** Add and manage characters (player characters) within each campaign.
|
||||
* **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.
|
||||
* Campaign cards display the number of characters and encounters.
|
||||
* **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 and monsters to encounters with initiative scores and hit points.
|
||||
* DM controls to start, advance turns, and end encounters.
|
||||
* 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.
|
||||
* Monsters have an editable default initiative modifier (pre-filled at +2) and can be marked as NPCs.
|
||||
* "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 and participant HP (monster HP totals are hidden, only the bar is shown).
|
||||
* Option to set a custom background image URL for the player display on a per-campaign basis.
|
||||
* DM can activate/deactivate encounters for the player display.
|
||||
* Player display can be opened in a separate window.
|
||||
* 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) to set a manual order.
|
||||
* **Responsive Design:** Styled with Tailwind CSS for usability across different screen sizes.
|
||||
* **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:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -34,33 +73,53 @@ A web-based application designed to help Dungeon Masters (DMs) manage and displa
|
||||
|
||||
## App Usage Overview
|
||||
|
||||

|
||||

|
||||
|
||||
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:
|
||||
|
||||
1. **Admin Interface (DM's View):**
|
||||
* **Create a Campaign:** Start by creating a new campaign. You can optionally provide a URL for a custom background image that will be shown on the Player Display for this campaign.
|
||||
* **Add Characters:** Within a campaign, add your player characters to the campaign roster.
|
||||
* **Create a Campaign:** Start by creating a new campaign. You can optionally provide a URL for a custom background image that will be shown on the Player Display for this campaign. Campaign cards will show a preview of this background.
|
||||
* **Add Characters:** Within a campaign, add your player characters. For each, set their name, default maximum HP, and a default initiative modifier. This section is collapsible to save screen space.
|
||||
* **Create Encounters:** For each combat scenario, create an encounter within the selected campaign.
|
||||
* **Manage Participants:**
|
||||
* Add characters from your campaign roster and any monsters to the selected encounter.
|
||||
* Set their initiative scores and maximum hit points.
|
||||
* If there are ties in initiative, you can drag and drop participants (before starting the encounter) to set a specific tie-breaker order.
|
||||
* Add characters from your campaign roster. Their Max HP will pre-fill from their campaign default, and their initiative will be automatically rolled (d20 + their default modifier).
|
||||
* Add monsters. Provide a name, Max HP, and an initiative modifier (defaults to +2 but is editable). Mark if the monster is an "NPC" for different styling. Their initiative will be automatically rolled (d20 + specified modifier).
|
||||
* Use the "Add All From Campaign (Roll Init)" button to quickly add all campaign characters not yet in the encounter, with their initiative rolled and HP set from defaults.
|
||||
* If there are ties in initiative, you can drag and drop participants (before starting the encounter or while it's paused) to set a specific tie-breaker order.
|
||||
* **Control Player Display:**
|
||||
* Use the "eyeball" icon next to an encounter to toggle it as "LIVE ON PLAYER DISPLAY". This controls what is shown on the separate Player Display window. Only one encounter can be live at a time.
|
||||
* Click the "Open Player Window" button in the header to launch a separate, clean window for your players (ideal for an external monitor).
|
||||
* Click the "Open Player Window" button in the header to launch a separate, clean window for your players (ideal for an external monitor). This window's content is controlled by the DM.
|
||||
* Use the "eyeball" icon next to an encounter to toggle it as "LIVE ON PLAYER DISPLAY". This controls what is shown on the Player Display window. Only one encounter can be live at a time.
|
||||
* **Run Combat:**
|
||||
* Once participants are added and initiative is set (including any manual tie-breaking), click "Start Encounter". This also automatically makes the encounter live on the Player Display.
|
||||
* Use the "Next Turn" button to advance through the initiative order. The current combatant will be highlighted in both the Admin View and the Player Display.
|
||||
* Apply damage or healing to participants directly in the Admin View during combat.
|
||||
* Mark participants as inactive (e.g., if knocked out) using the toggle next to their name.
|
||||
* Click "End Encounter" when combat is over. This will also deactivate it from the Player Display.
|
||||
* 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. 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) shows a simplified view of the active encounter.
|
||||
* It displays the initiative order, current turn, round number, and participant HP (monster HP values are hidden, only the bar is shown).
|
||||
* If a custom background URL was set for the campaign, it will be displayed.
|
||||
* If no encounter is active on the Player Display, it will show a "Game Session Paused" message.
|
||||
* 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.
|
||||
|
||||
@@ -69,67 +128,65 @@ This flow allows the DM to prepare and run encounters efficiently while providin
|
||||
### Prerequisites
|
||||
|
||||
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
|
||||
* **Firebase Project:** You'll need a Firebase project with Firestore Database and Anonymous Authentication enabled.
|
||||
* **Firebase Project:** You'll need a Firebase project with:
|
||||
* Firestore Database created and initialized.
|
||||
* Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab.
|
||||
* **Git:** For cloning the repository.
|
||||
|
||||
### Local Development Setup (using npm)
|
||||
|
||||
#### **Clone the Repository:**
|
||||
```bash
|
||||
git clone <your-repository-url>
|
||||
cd ttrpg-initiative-tracker
|
||||
```
|
||||
|
||||
#### **Create Firebase Configuration File (`.env.local`):**
|
||||
* In the root of the project, create a file named `.env.local`.
|
||||
* Add your Firebase project configuration details to this file. You can find these in your Firebase project settings (Project settings > General > Your apps > Firebase SDK snippet > Config).
|
||||
* **Important:** This `.env.local` file contains sensitive API keys and should **NOT** be committed to Git. Make sure it's listed in your `.gitignore` file.
|
||||
|
||||
Your `.env.local` should look like this:
|
||||
```ini
|
||||
REACT_APP_FIREBASE_API_KEY="YOUR_FIREBASE_API_KEY"
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN="YOUR_FIREBASE_AUTH_DOMAIN"
|
||||
REACT_APP_FIREBASE_PROJECT_ID="YOUR_FIREBASE_PROJECT_ID"
|
||||
REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET"
|
||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID"
|
||||
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID"
|
||||
|
||||
# Used for namespacing Firestore paths, can be any unique string for your app instance
|
||||
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-dev"
|
||||
```
|
||||
*An `.env.example` file is included in the repository as a template.*
|
||||
|
||||
#### **Install Dependencies:**
|
||||
Navigate to the project root in your terminal and run:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
This will install all the necessary packages defined in `package.json` and create a `package-lock.json` file.
|
||||
|
||||
#### **Set up Firestore Security Rules:**
|
||||
* Go to your Firebase project console -> Firestore Database -> Rules.
|
||||
* Use the following rules for development (allows authenticated users to read/write all data). **For production, you MUST implement more restrictive rules.**
|
||||
1. **Clone the Repository:**
|
||||
```bash
|
||||
git clone https://code.draft13.com/robert/ttrpg-initiative-tracker
|
||||
cd ttrpg-initiative-tracker
|
||||
```
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
match /{document=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2. **Create Firebase Configuration File (`.env.local`):**
|
||||
* In the root of the project, create a file named `.env.local`.
|
||||
* Add your Firebase project configuration details to this file. You can find these in your Firebase project settings (Project settings > General > Your apps > Firebase SDK snippet > Config).
|
||||
* **Important:** This `.env.local` file contains sensitive API keys and should **NOT** be committed to Git. Make sure it's listed in your `.gitignore` file.
|
||||
|
||||
Your `.env.local` should look like this:
|
||||
```ini
|
||||
REACT_APP_FIREBASE_API_KEY="YOUR_FIREBASE_API_KEY"
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN="YOUR_FIREBASE_AUTH_DOMAIN"
|
||||
REACT_APP_FIREBASE_PROJECT_ID="YOUR_FIREBASE_PROJECT_ID"
|
||||
REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET"
|
||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID"
|
||||
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID"
|
||||
|
||||
# Used for namespacing Firestore paths, can be any unique string for your app instance
|
||||
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-dev"
|
||||
```
|
||||
* Publish these rules.
|
||||
*An `.env.example` file is included in the repository as a template.*
|
||||
|
||||
#### **Enable Anonymous Authentication:**
|
||||
* In your Firebase project console -> Authentication -> Sign-in method.
|
||||
* Enable "Anonymous" as a sign-in provider.
|
||||
3. **Install Dependencies:**
|
||||
Navigate to the project root in your terminal and run:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
This will install all the necessary packages defined in `package.json` and create a `package-lock.json` file.
|
||||
|
||||
#### **Run the Development Server:**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
This will start the React development server, usually on `http://localhost:3000`. The application will open in your default web browser.
|
||||
4. **Set up Firestore Security Rules:**
|
||||
* Go to your Firebase project console -> Firestore Database -> Rules.
|
||||
* Use the following rules for development (allows authenticated users to read/write all data). **For production, you MUST implement more restrictive rules.**
|
||||
```
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
match /{document=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
* Publish these rules.
|
||||
|
||||
5. **Run the Development Server:**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
This will start the React development server, usually on `http://localhost:3000`. The application will open in your default web browser.
|
||||
|
||||
### Deployment with Docker
|
||||
|
||||
@@ -137,60 +194,60 @@ This project includes a `Dockerfile` to containerize the application for deploym
|
||||
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
|
||||
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
|
||||
|
||||
**1. Prerequisites for Docker:**
|
||||
* Ensure Docker Desktop (or Docker Engine on Linux) is installed and running. Download from [docker.com](https://www.docker.com/products/docker-desktop).
|
||||
1. **Prerequisites for Docker:**
|
||||
* Ensure Docker Desktop (or Docker Engine on Linux) is installed and running. Download from [docker.com](https://www.docker.com/products/docker-desktop).
|
||||
|
||||
**2. Building the Docker Image (for Local Testing with `.env.local`):**
|
||||
* Make sure your `.env.local` file is present in the project root and correctly configured with your Firebase details. The `Dockerfile` is set up to copy this file as `.env` during the build for local testing.
|
||||
* **Security Warning:** The image built this way will contain your Firebase API keys from `.env.local`. **Do NOT push this specific image to a public Docker registry.** For production, environment variables should be injected by your hosting platform or CI/CD pipeline at build or runtime.
|
||||
2. **Building the Docker Image (for Local Testing with `.env.local`):**
|
||||
* Make sure your `.env.local` file is present in the project root and correctly configured with your Firebase details. The `Dockerfile` is set up to copy this file as `.env` during the build for local testing.
|
||||
* **Security Warning:** The image built this way will contain your Firebase API keys from `.env.local`. **Do NOT push this specific image to a public Docker registry.** For production, environment variables should be injected by your hosting platform or CI/CD pipeline at build or runtime.
|
||||
|
||||
To build the image, navigate to the project root and run:
|
||||
To build the image, navigate to the project root and run:
|
||||
```bash
|
||||
docker build -t ttrpg-initiative-tracker .
|
||||
```
|
||||
*(You can replace `ttrpg-initiative-tracker` with your preferred image name/tag).*
|
||||
|
||||
```bash
|
||||
docker build -t ttrpg-initiative-tracker .
|
||||
```
|
||||
*(You can replace `ttrpg-initiative-tracker` with your preferred image name/tag).*
|
||||
3. **Running the Docker Container Locally:**
|
||||
Once the image is built, run it:
|
||||
```bash
|
||||
docker run -p 8080:80 --rm --name ttrpg-tracker-app ttrpg-initiative-tracker
|
||||
```
|
||||
* `-p 8080:80`: Maps port 8080 on your host machine to port 80 inside the container (where Nginx is listening).
|
||||
* `--rm`: Automatically removes the container when it stops.
|
||||
* `--name ttrpg-tracker-app`: Assigns a name to the running container.
|
||||
* `ttrpg-initiative-tracker`: The name of the image you built.
|
||||
|
||||
**3. Running the Docker Container Locally:**
|
||||
Once the image is built, run it:
|
||||
```bash
|
||||
docker run -p 8080:80 --rm --name ttrpg-tracker-app ttrpg-initiative-tracker
|
||||
```
|
||||
* `-p 8080:80`: Maps port 8080 on your host machine to port 80 inside the container (where Nginx is listening).
|
||||
* `--rm`: Automatically removes the container when it stops.
|
||||
* `--name ttrpg-tracker-app`: Assigns a name to the running container.
|
||||
* `ttrpg-initiative-tracker`: The name of the image you built.
|
||||
You can then access the application at `http://localhost:8080`.
|
||||
|
||||
You can then access the application at `http://localhost:8080`.
|
||||
|
||||
**4. Production Deployment Considerations:**
|
||||
|
||||
* When deploying to a production environment (e.g., a cloud provider, your own server), you should **not** copy your `.env.local` file into the Docker image.
|
||||
* Instead, configure the `REACT_APP_FIREBASE_...` environment variables directly in your hosting platform's settings or pass them to the Docker container at runtime (if your application is set up to read them at runtime, though Create React App bakes them in at build time).
|
||||
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
|
||||
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
|
||||
4. **Production Deployment Considerations:**
|
||||
* When deploying to a production environment (e.g., a cloud provider, your own server), you should **not** copy your `.env.local` file into the Docker image.
|
||||
* Instead, configure the `REACT_APP_FIREBASE_...` environment variables directly in your hosting platform's settings or pass them to the Docker container at runtime (if your application is set up to read them at runtime, though Create React App bakes them in at build time).
|
||||
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
|
||||
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
|
||||
|
||||
## Project Structure
|
||||
|
||||
`ttrpg-initiative-tracker/`
|
||||
- ` .dockerignore` # Specifies intentionally untracked files that Docker should ignore
|
||||
- ` .env.example` # Example environment variables
|
||||
- ` .env.local` # Local environment variables (ignored by Git)
|
||||
- ` .gitignore` # Specifies intentionally untracked files that Git should ignore
|
||||
- ` Dockerfile` # Instructions to build the Docker image
|
||||
- ` package-lock.json` # Records exact versions of dependencies
|
||||
- ` package.json` # Project metadata and dependencies
|
||||
- ` postcss.config.js` # PostCSS configuration (for Tailwind CSS)
|
||||
- ` tailwind.config.js` # Tailwind CSS configuration
|
||||
- ` public/` # Static assets
|
||||
- ` favicon.ico`
|
||||
- ` index.html` # Main HTML template
|
||||
- ` manifest.json`
|
||||
- ` src/` # React application source code
|
||||
- ` App.js` # Main application component
|
||||
- ` index.css` # Global styles (including Tailwind directives)
|
||||
- ` index.js` # React entry point
|
||||
<pre>
|
||||
ttrpg-initiative-tracker/
|
||||
├── .dockerignore # Specifies intentionally untracked files that Docker should ignore
|
||||
├── .env.example # Example environment variables
|
||||
├── .env.local # Local environment variables (ignored by Git)
|
||||
├── .gitignore # Specifies intentionally untracked files that Git should ignore
|
||||
├── Dockerfile # Instructions to build the Docker image
|
||||
├── package-lock.json # Records exact versions of dependencies
|
||||
├── package.json # Project metadata and dependencies
|
||||
├── postcss.config.js # PostCSS configuration (for Tailwind CSS)
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── public/ # Static assets
|
||||
│ ├── favicon.ico
|
||||
│ ├── index.html # Main HTML template
|
||||
│ └── manifest.json
|
||||
└── src/ # React application source code
|
||||
├── App.js # Main application component
|
||||
├── index.css # Global styles (including Tailwind directives)
|
||||
└── index.js # React entry point
|
||||
</pre>
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to fork the repository and submit pull requests. For major changes, please open an issue first to discuss what you would like to change.
|
||||
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
|
||||
|
||||
@@ -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}`);
|
||||