diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 685ae56..2ecb52a 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -35,9 +35,15 @@ jobs: - name: Typecheck working-directory: client run: bun run typecheck + - name: Lint + working-directory: client + run: bun run lint + - name: Format + working-directory: client + run: bun run format:check - name: Test working-directory: client - run: bun test + run: bun run test:coverage - name: Build working-directory: client run: bun run build diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 5512830..44b80ee 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -27,6 +27,7 @@ jobs: - uses: dtolnay/rust-toolchain@1.97.1 with: targets: wasm32v1-none + components: rustfmt, clippy - uses: actions/cache@v4 with: path: | @@ -34,6 +35,12 @@ jobs: ~/.cargo/registry/cache ~/.cargo/git/db key: cargo-${{ runner.os }}-1.97.1-${{ hashFiles('contracts/deepmark-contract/Cargo.lock') }} + - name: Format + working-directory: contracts/deepmark-contract + run: cargo fmt --check + - name: Clippy + working-directory: contracts/deepmark-contract + run: cargo clippy --all-targets -- -D warnings - name: Test working-directory: contracts/deepmark-contract run: cargo test diff --git a/.github/workflows/diagrams.yml b/.github/workflows/diagrams.yml new file mode 100644 index 0000000..ead2235 --- /dev/null +++ b/.github/workflows/diagrams.yml @@ -0,0 +1,35 @@ +name: diagrams + +on: + pull_request: + paths: + - diagrams/** + - .github/workflows/diagrams.yml + push: + branches: [main] + paths: + - diagrams/** + - .github/workflows/diagrams.yml + +permissions: + contents: read + +concurrency: + group: diagrams-${{ github.ref }} + cancel-in-progress: true + +jobs: + diagrams: + name: diagrams + runs-on: ubuntu-latest + defaults: + run: + working-directory: diagrams + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run check diff --git a/CLAUDE.md b/CLAUDE.md index c16c352..75cc830 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Deepmark -Deepmark is a turn-based dungeon-crawler roguelite with a bank-or-push extraction mechanic, built on Stellar/Soroban for the Find Your Way Meridian Hackathon (submission deadline: October 5, 2026, 4:00 PM Costa Rica time). See `docs/architecture/onchain-boundary.md` for what lives on-chain and why. +Deepmark is a turn-based dungeon-crawler roguelite with a bank-or-push extraction mechanic, built on Stellar/Soroban for the Find Your Way Meridian Hackathon (submission deadline: October 12, 2026). See `docs/architecture/onchain-boundary.md` for what lives on-chain and why. ## Mission diff --git a/README.md b/README.md index 8e12cbf..6b9ec29 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,26 @@ A turn-based dungeon crawler where the Stellar contract replays every run, so a result or a relic cannot be claimed without playing for it. +**Play now: https://deepmark-dun.vercel.app** (Stellar testnet, no wallet or sign-up) + +- A run is two transactions, and the contract replays your moves itself instead of believing a score. +- Relics are real Stellar assets that only a verified extraction can mint, from an issuer account that is locked. +- Anyone can re-check any run with one read only command, see [Verify a run in 60 seconds](#verify-a-run-in-60-seconds). + ![Deepmark title. A hooded figure with a lantern stands at the lip of a stone shaft, and the menu sits on the dark rock to the left.](docs/screenshots/title.jpg) -Built for the Find Your Way Meridian Hackathon. It runs on Stellar testnet. No wallet, extension or sign-up is needed: the game creates a testnet account in your browser on the first run. +Built for the Find Your Way Meridian Hackathon. The game creates a testnet account in your browser on the first run. No wallet or extension is needed. + +## Watch + +| | | | +|:---:|:---:|:---:| +| [![Deepmark trailer, 30 seconds. A hooded figure with a lantern at the lip of a stone shaft.](https://i.ytimg.com/vi/X5GZSp7EFE8/maxresdefault.jpg)](https://youtu.be/X5GZSp7EFE8) | [![Deepmark trailer, 90 seconds, with the contract verification explained.](https://i.ytimg.com/vi/F-gPDj1Yt5A/maxresdefault.jpg)](https://youtu.be/F-gPDj1Yt5A) | [![Deepmark animated share card.](https://i.ytimg.com/vi/uJX_Ii3xAXE/hqdefault.jpg)](https://youtu.be/uJX_Ii3xAXE) | +| [**Trailer, 30 s**](https://youtu.be/X5GZSp7EFE8)
The game in motion, recorded from the production build. | [**Trailer, 90 s**](https://youtu.be/F-gPDj1Yt5A)
Adds what the contract verifies and why a result cannot be forged. | [**Share card**](https://youtu.be/uJX_Ii3xAXE)
The animated card a player shares after a verified run. | ## Play in one minute -The repository is private until the submission freeze, so the live link is added here when it goes public. Until then: +To run it locally instead of the live link: ```bash cd client @@ -28,44 +41,133 @@ Other keys: **L** leaderboard, **V** vault, **M** your record, **N** sound. On a ![Floor 1. Open stone is lighter than the blocked rock, with a quiet gray seam. Gold corners mark the stairs.](docs/screenshots/floor.jpg) +![A delver fights the guardian of floor 3. The hit lands, the guard staggers and the screen shakes.](docs/media/boss-fight.gif) + If Stellar cannot be reached, the game still plays as a practice run. The floor label says Practice, and that result is not recorded. ## Why Stellar - **Verified results.** A run is two transactions: `start_run`, where the contract issues the seed, and `finish_run`, where it replays your action log and derives the floor, score and relics itself. A forged log fails or produces a legitimate run. -- **Scarce relics.** `DMCOMMON`, `DMRARE` and `DMCORE` are Stellar assets wrapped by Stellar Asset Contracts that the game contract administers. Nothing is for sale, and no relic is minted without a verified extraction. The caveat for the current testnet issuer is under Limitations. +- **Relics from a locked issuer.** `DMCOMMON`, `DMRARE` and `DMCORE` are classic Stellar assets wrapped by Stellar Asset Contracts that the game contract administers. The issuer account has a single signer with weight 0, so only a verified extraction can mint. Nothing is for sale. The limits of that claim are under Limitations. - **A ladder anyone can audit.** `verify_run` replays any run with no wallet and no write, from the seed in the `run_started` event and the log. +- **Why this chain and not a database.** Ledgers close about every five seconds (the setup transactions in `docs/deployments.md` are five seconds apart), a whole run replays inside one Soroban transaction, and classic trustlines, Friendbot and Stellar Asset Contracts give a new player an account and a real asset with no wallet in sight. A database could also store a score. It could not let a stranger check that the operator did not edit it. + +The game is built so it is fun with the chain switched off in your head. Rendering, animation and sound never wait on a transaction, and the two calls happen while the descent plays and after the result is already on screen. The boundary and its reasons are in [`docs/architecture/onchain-boundary.md`](docs/architecture/onchain-boundary.md). + +![System context. The browser holds the game, the simulation and the player key, talks to Soroban RPC, Horizon and Friendbot, and calls the game contract, which mints through three Stellar Asset Contracts.](diagrams/out/svg/01-system-context.svg) + +![One run, two transactions. start_run issues the seed while the descent plays, the client plays locally, and finish_run replays the log, writes the profile and mints.](diagrams/out/svg/02-run-sequence.svg) + +## Verify a run in 60 seconds + +You need the [Stellar CLI](https://developers.stellar.org/docs/tools/cli/install-cli) and nothing else. No account of yours is used and nothing is sent: `--send no` simulates the call, and the source account is the public admin address, which only has to exist. + +The run below is a full clear on the live testnet contract. Its seed comes from the `run_started` event of the [`start_run` transaction](https://stellar.expert/explorer/testnet/tx/5fdccef0e229953a20cd832e6dce9d5732065ee604b3a3c1b9cec3ec791ca090) (`seed` 1090415896, base 0, weapon 1, charm 0, rank 0), and its action log is the argument of the [`finish_run` transaction](https://stellar.expert/explorer/testnet/tx/349e4df814b2a0f3b6a530d05855a5d70f935a321fac37067de62a6de12e7524). + +```bash +# 1. Read the action log out of the finish_run transaction (optional, it is also printed below) +curl -s https://horizon-testnet.stellar.org/transactions/349e4df814b2a0f3b6a530d05855a5d70f935a321fac37067de62a6de12e7524 \ + | jq -r .envelope_xdr \ + | stellar xdr decode --type TransactionEnvelope --input single-base64 --output json \ + | jq -r '.tx.tx.operations[0].body.invoke_host_function.host_function.invoke_contract.args[1].bytes' + +# 2. Ask the contract to replay it +stellar contract invoke \ + --id CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX \ + --network testnet \ + --source-account GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX \ + --send no \ + -- verify_run \ + --base 0 --weapon 1 --charm 0 --rank 0 --seed 1090415896 \ + --actions 030303030405030403030202000000030301030103010306030300010300000100000304040101010304040303020200000003030103010301030603030303050305030404030303030303 +``` + +Expected output: + +```json +{"actions":75,"extracted":true,"floor_reached":3,"hero_id":null,"hp_left":3,"rank_key":288084998468608,"relics_common":1,"relics_core":1,"relics_rare":1,"score":6} +``` + +Compare it with the `run_finished` event on the `finish_run` transaction, where the contract derived the same floor (3), HP left (3), score (6), actions (75) and relics (one of each). That transaction also carries the `hero_earned` event for the first full clear (delver 1) and three `mint` events from the relic contracts. `hero_id` is `null` in the read only replay because `verify_run` never mints. Change one byte of the log, or the seed, and the call fails or returns a different result. Both commands were run on 2026-10-12 against the deployment below. + +To check that nobody else can mint relics: -The game is built so it is fun with the chain switched off in your head. Rendering, animation and sound never wait on a transaction, and the two calls happen while the descent plays and after the result is already on screen. The boundary and its reasons are in `docs/architecture/onchain-boundary.md`. +```bash +curl -s https://horizon-testnet.stellar.org/accounts/GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q | jq '{signers, thresholds}' +stellar contract invoke --id CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R --network testnet \ + --source-account GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX --send no -- admin +``` + +The first shows one signer with weight 0 and all thresholds 0 (the [lock transaction](https://stellar.expert/explorer/testnet/tx/ddae4f0c5f28ff1bc08af97949dd85861fe3b087eefb67b1e4893b354c514e28)). The second prints the game contract as the admin of `DMCOMMON`. ## On testnet +Redeployed on 2026-10-12. The earlier deployments are retired and listed in [`docs/deployments.md`](docs/deployments.md). + | Item | Id | |---|---| -| Game contract | `CDEAFSBG7JBN6XMP4HRL7LFBL7L67K4RJK3CIRBV23W3TJQCEADZ7R6N` | -| `DMCOMMON` contract | `CAAFYL2QZU274NLA2IFDB4L6SUNVQKT6OLB6UXGL5L5LMQCFJXSUYFO3` | -| `DMRARE` contract | `CC4RQ4HLTPTXMCZ3OKDL546XXJGOQ5PI2ET6U33C2XTO4BTS6AJV4ANA` | -| `DMCORE` contract | `CC27JVKNDSUM5FDGP4JZDZTKGHJ5RJV7XHJGITTUGOUFE4DVPN6PGLMI` | -| Relic issuer (locked) | `GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK` | -| Contract admin | `GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ` | +| Game contract | [`CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX`](https://stellar.expert/explorer/testnet/contract/CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX) | +| `DMCOMMON` contract | [`CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R`](https://stellar.expert/explorer/testnet/contract/CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R) | +| `DMRARE` contract | [`CCQFEYZR3D4XHTMLV3XJZBTZUT4WXZS2LUXHPFTH7DRRZENZNU2NVZ5L`](https://stellar.expert/explorer/testnet/contract/CCQFEYZR3D4XHTMLV3XJZBTZUT4WXZS2LUXHPFTH7DRRZENZNU2NVZ5L) | +| `DMCORE` contract | [`CAHKK75BOXGBKSXBB3EFPWS6C3IZIXIFLYI3A74GYCRSI4U6NKVZTLIM`](https://stellar.expert/explorer/testnet/contract/CAHKK75BOXGBKSXBB3EFPWS6C3IZIXIFLYI3A74GYCRSI4U6NKVZTLIM) | +| Relic issuer (locked) | [`GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q`](https://stellar.expert/explorer/testnet/account/GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q) | +| Contract admin | [`GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX`](https://stellar.expert/explorer/testnet/account/GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX) | +| Wasm | 28,107 bytes, sha256 `db721cb97242bf5d9710d70752959a5658c2be81fee70d5fdd7253b878f80d07` | + +Asset pages with supply and holders: [`DMCOMMON`](https://stellar.expert/explorer/testnet/asset/DMCOMMON-GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q), [`DMRARE`](https://stellar.expert/explorer/testnet/asset/DMRARE-GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q) and [`DMCORE`](https://stellar.expert/explorer/testnet/asset/DMCORE-GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q). + +Proof transactions, all `successful` on testnet. Each run was played through the real client on a fresh throwaway account. + +- [`Issuer lock`](https://stellar.expert/explorer/testnet/tx/ddae4f0c5f28ff1bc08af97949dd85861fe3b087eefb67b1e4893b354c514e28): master key weight set to 0. +- First `start_run` on the deployment, a new player: [`d1dc76e7...100b`](https://stellar.expert/explorer/testnet/tx/d1dc76e76d4498adec6396fccdc9cad8e0314b832c8d0f0e8908a461c76e100b). +- Early extraction on floor 1, mints `DMCOMMON`: [`aecb8b3f...db6a`](https://stellar.expert/explorer/testnet/tx/aecb8b3fa89b0340ce84b6695ea2570d3e327de010e79f460447e63c6d53db6a). +- Extraction on floor 2, mints `DMCOMMON` and `DMRARE`: [`51d74c53...e7cc`](https://stellar.expert/explorer/testnet/tx/51d74c53126f42064f0208346da18ee941289bc1009ae72e8b9b8fd70380e7cc). +- Death on floor 2, recorded with no relics: [`bd264709...4841`](https://stellar.expert/explorer/testnet/tx/bd2647098500dc954db3a4c989471ba703110343343798982587b91566c54841). +- Full clear, mints all three relics and earns delver 1 (`hero_earned`): [`349e4df8...7524`](https://stellar.expert/explorer/testnet/tx/349e4df814b2a0f3b6a530d05855a5d70f935a321fac37067de62a6de12e7524), started by [`5fdccef0...a090`](https://stellar.expert/explorer/testnet/tx/5fdccef0e229953a20cd832e6dce9d5732065ee604b3a3c1b9cec3ec791ca090). +- Veteran run with that delver, mints `DMCORE`: [`5b74020c...9c3f`](https://stellar.expert/explorer/testnet/tx/5b74020c03ac5dc0e43d4092ee2dd3338eaf220dafc76f67ce521bab53609c3f), started by [`9ca436ee...a9cc`](https://stellar.expert/explorer/testnet/tx/9ca436ee1f760f5d5d8a1cb8b8c12263dfca98423af55f78a1660fd77aa3a9cc). +- A second full clear, which earns delver 0: [`7881adab...9334`](https://stellar.expert/explorer/testnet/tx/7881adabe0dd17c9319a9b975d8c7e76796bb112214df5dac114d31805159334), started by [`7abbf360...1f09`](https://stellar.expert/explorer/testnet/tx/7abbf360511ef3d548b167a69507c9cd57272e5ff93256277da12fd1df441f09). +- Deploy the game contract: [`dd0f5f24...1313`](https://stellar.expert/explorer/testnet/tx/dd0f5f24645ebe85f14e63ec05a38b51e0979f29032dd14980990dd0d5d61313) and upload its wasm: [`593d2ea0...369f`](https://stellar.expert/explorer/testnet/tx/593d2ea0e7a185d29bb8f498755c33acb69138aee50aab8208a016e51ac6369f). + +Every setup transaction is in [`docs/deployments.md`](docs/deployments.md). The same ids are in `client/src/stellar/config.ts`. `/stats` in the running client is a public page that lists every address with a finished run, read from the contract. + +The playtest behind these transactions created throwaway accounts, each with its three relic trustlines, so the holder counts Horizon reports for the relics count test accounts, not players. After the six runs the authorized supply was 4 `DMCOMMON`, 3 `DMRARE` and 3 `DMCORE`, exactly the sum of the mints in the `run_finished` events. + +## Measured costs -The setup and verification transactions are listed in `docs/deployments.md`. Look any of them up on [stellar.expert](https://stellar.expert/explorer/testnet). The same ids are in `client/src/stellar/config.ts`. `/stats` in the running client is a public page that lists every address with a finished run, read from the contract. +Read from the transactions of the current deployment through Horizon (`fee_charged`) and the Soroban `rentFeeCharged` in each transaction's result meta. One XLM is 10,000,000 stroops. All on testnet, none on mainnet, so these say nothing about mainnet fees. + +| Call | Transaction | Fee charged (stroops) | Of which rent | XLM | +|---|---|---|---|---| +| `start_run`, first call on the contract (new player) | [`d1dc76e7...100b`](https://stellar.expert/explorer/testnet/tx/d1dc76e76d4498adec6396fccdc9cad8e0314b832c8d0f0e8908a461c76e100b) | 8,970,532 | 8,947,995 | 0.897 | +| `start_run`, another new player | [`a6ebcd48...91e8`](https://stellar.expert/explorer/testnet/tx/a6ebcd48129c055face7b92f484343f098e673c59083ed06245669203f6d91e8) | 7,910,423 | 7,887,853 | 0.791 | +| `start_run`, returning player with a veteran | [`9ca436ee...a9cc`](https://stellar.expert/explorer/testnet/tx/9ca436ee1f760f5d5d8a1cb8b8c12263dfca98423af55f78a1660fd77aa3a9cc) | 55,755 | 39,321 | 0.0056 | +| `finish_run`, death | [`bd264709...4841`](https://stellar.expert/explorer/testnet/tx/bd2647098500dc954db3a4c989471ba703110343343798982587b91566c54841) | 2,794,495 | 2,776,162 | 0.279 | +| `finish_run`, extraction with 1 relic | [`aecb8b3f...db6a`](https://stellar.expert/explorer/testnet/tx/aecb8b3fa89b0340ce84b6695ea2570d3e327de010e79f460447e63c6d53db6a) | 3,860,064 | 3,836,344 | 0.386 | +| `finish_run`, extraction with 2 relics | [`51d74c53...e7cc`](https://stellar.expert/explorer/testnet/tx/51d74c53126f42064f0208346da18ee941289bc1009ae72e8b9b8fd70380e7cc) | 2,806,358 | 2,776,171 | 0.281 | +| `finish_run`, full clear, 3 relics, delver earned | [`349e4df8...7524`](https://stellar.expert/explorer/testnet/tx/349e4df814b2a0f3b6a530d05855a5d70f935a321fac37067de62a6de12e7524) | 9,982,709 | 9,934,956 | 0.998 | +| `finish_run`, veteran, 1 relic | [`5b74020c...9c3f`](https://stellar.expert/explorer/testnet/tx/5b74020c03ac5dc0e43d4092ee2dd3338eaf220dafc76f67ce521bab53609c3f) | 3,913,761 | 3,886,278 | 0.391 | + +What the table shows. More than 99% of the start fee is rent, and most of it is one time: a new player pays about 0.79 XLM once for their persistent record, then about 0.006 XLM per later start. A complete fresh run (new player, full clear, delver minted) costs about 1.8 to 1.9 XLM and a death about 1.07 XLM, dominated by one time storage for the player record, the delver and the three relic balances. The first `start_run` on this deployment cost 0.897 XLM, about 0.105 XLM above a normal new player start. The earlier deployment charged 183.9 XLM for its first call. This deployment extended the lifetime of the contract instance and its wasm right after deploying ([instance](https://stellar.expert/explorer/testnet/tx/3a174eec310202b9124525f8099e6228a8042c310ce08838b7a2b47a0609d4ba) and [wasm](https://stellar.expert/explorer/testnet/tx/ae8a038a8470479fc2fa603dee70d718b7f75838bed101d13a09739661c389fb)), which is the change that fits the drop, since the first call no longer has to pay that rent. No `run_started` event existed before run 1 (checked with Soroban RPC `getEvents` over the preceding 100,000 ledgers), so run 1 was the first call. The compute side is small: the worst case in the test suite (2,000 inputs) uses about 3.6 million instructions against a 400 million limit. ## Limitations - **Testnet only.** There is no mainnet deployment. -- **The contract has an admin who can upgrade it.** Replay verification protects players from forged logs, not from the admin while the role exists. The role is meant to be renounced or moved to a multisig before any mainnet release. Details in `docs/threat-model.md`. -- **Delvers are not standard NFTs.** They have an owner and transfer interface shaped like the Stellar standard and differ from it in several calls and events. Explorer support has not been tested and is not claimed. See `docs/architecture/nft-standard.md`. -- **Seed rerolling is limited, not removed.** A cooldown and a visible abandoned run counter make it cost time. A solver can also play a legal perfect run. +- **The contract has an admin who can upgrade it.** Replay verification protects players from forged logs, not from the admin while the role exists. The role is meant to be renounced or moved to a multisig before any mainnet release, and there is no timelock on `upgrade`. Details in [`docs/threat-model.md`](docs/threat-model.md). +- **Relics are not capped.** The issuer is locked and only a verified run mints, but nothing limits how many verified runs one person makes. A bot can solve a seed offline and use many free testnet accounts, so a relic proves a legal run was replayed, not that a distinct person played it. +- **Seed rerolling is limited, not removed.** A cooldown and a visible abandoned run counter make it cost time, but both are per address and a new address resets them. A solver can also play a legal perfect run. +- **Some idle data can still expire.** `start_run` renews the player counter, the delver counter, both leaderboards and the delver a veteran run uses. A player's own record and the player index entries are renewed only when written, so an account that does not play for about 120 days can have them archived. They can be restored, nothing is lost, but the client does not restore them yet. +- **New players register one per ledger.** A new player is appended to a sequential index (`PlayerAt(i)`), so several brand new players whose `start_run` lands in the same ledger simulate the same next index, and the later transactions fail with `storage exceeded_limit` on the key `PlayerAt N`. In the playtest, 3 fresh accounts starting together gave 2 failures out of 3. This is a throughput limit of new player registration, about one per ledger (about 5 seconds). The client detects a failed `start_run` (`client/src/stellar/txOutcome.ts`), retries up to 4 times with jittered backoff and re-simulates, and a local re-run had 6 of 6 starts succeed. A returning player is never affected. A registry keyed by address would remove the limit in a later contract version. +- **Finishing needs the relic trustlines.** If a relic mint is rejected (for example no trustline), `finish_run` fails with `MintFailed` and the run stays open, so it can be finished again once the cause is fixed. The client adds the trustlines at account setup. +- **Delvers are not standard NFTs.** They have an owner and transfer interface shaped like the Stellar standard and differ from it in several calls and events. Explorer support has not been tested and is not claimed. See [`docs/architecture/delvers-and-nft-standard.md`](docs/architecture/delvers-and-nft-standard.md). - **The key lives in the browser.** Clearing site data loses the account and its records. A passkey account is the planned replacement. -- **No third party audit.** The review that exists is described in `docs/threat-model.md`. -- **The board is not exposed to screen readers.** See `docs/accessibility.md`. +- **No third party audit.** The review that exists is described in the threat model. +- **The board is not exposed to screen readers.** See [`docs/accessibility.md`](docs/accessibility.md). ## Check it yourself ```bash cd contracts/deepmark-contract -cargo test # 63 tests plus 600 vectors played by the real client +cargo test # 80 tests plus 600 vectors played by the real client stellar contract build cd ../../client @@ -79,6 +181,14 @@ bun run contrast # contrast ratios from the stylesheet GitHub Actions runs the client checks when `client/` changes, the contract checks when `contracts/` changes, and the performance and accessibility checks in a pinned Chrome. +## Roadmap + +Next: share cards and challenge links, a decision on the contract admin role with a date, then accounts that survive clearing the browser, sponsored fees and a first season. Mainnet comes only after the admin is renounced or moved to a multisig and an independent review is done. The full plan, with what is still unverified, is in [`docs/roadmap.md`](docs/roadmap.md). + +## Business model + +Deepmark is a free game with no token and nothing for sale that makes a delver stronger. If it earns money, the plan is sponsored seasonal ladders, where a sponsor funds the prizes and results are paid out only for runs the contract has replayed, with optional cosmetics that never touch play. Nothing here is signed or live. The reasoning and what is not planned are in [`docs/business-model.md`](docs/business-model.md). + ## Where things are | Path | What | @@ -86,13 +196,24 @@ GitHub Actions runs the client checks when `client/` changes, the contract check | `client/` | The game: TypeScript, Vite, canvas. `src/game` is the simulation, `src/render` the screens, `src/stellar` the chain code | | `contracts/deepmark-contract/` | The Soroban contract. `src/{rng,world,sim}.rs` is a bit exact port of `client/src/game/` | | `docs/architecture/onchain-boundary.md` | What is on chain, what is not, and why | -| `docs/architecture/nft-standard.md` | Delvers compared with the Stellar NFT standard | +| `docs/architecture/delvers-and-nft-standard.md` | Delvers compared with the Stellar NFT standard | | `docs/deployments.md` | Testnet ids and the transaction of every deployment step | | `docs/threat-model.md` | The attacks considered, what stops each, what remains | +| `docs/roadmap.md`, `docs/business-model.md` | Where the project is going and how it could be funded | | `docs/performance.md`, `docs/accessibility.md` | Budgets and accessibility, with how they are measured | | `docs/submission-checklist.md` | What is done and what is still open | -The contract API, the action encoding and the trust model are in `contracts/deepmark-contract/README.md`. +The contract API, the action encoding and the trust model are in [`contracts/deepmark-contract/README.md`](contracts/deepmark-contract/README.md). + +## Resumen en espanol + +Deepmark es un juego de mazmorras por turnos. Cuando sales de la mazmorra, el contrato de Stellar vuelve a jugar tu partida movimiento por movimiento a partir de la semilla que el mismo contrato te dio, y calcula el resultado por su cuenta. Por eso nadie puede declarar un puntaje, un lugar en el ranking o una reliquia sin haber jugado para conseguirlos. + +- Juega en https://deepmark-dun.vercel.app. Corre en la red de pruebas de Stellar y no necesitas billetera ni registro: el juego crea una cuenta de prueba en tu navegador. +- Cada partida son dos transacciones, `start_run` y `finish_run`. La animacion y el sonido nunca esperan a la red, y si Stellar no responde puedes seguir en una partida de practica que no se registra. +- Las reliquias (`DMCOMMON`, `DMRARE`, `DMCORE`) son activos de Stellar. La cuenta emisora esta bloqueada y solo una extraccion verificada puede crearlas. No se venden. +- Cualquiera puede comprobar una partida con un comando de solo lectura, como se muestra en "Verify a run in 60 seconds". +- Limites honestos: solo testnet, el contrato todavia tiene una llave de administrador que puede actualizarlo, no hay tope de reliquias, los delvers no son NFT estandar y no hay auditoria externa. La lista completa esta en la seccion Limitations y en [`docs/threat-model.md`](docs/threat-model.md). ## Licence diff --git a/client/biome.json b/client/biome.json new file mode 100644 index 0000000..cf443f1 --- /dev/null +++ b/client/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": false }, + "files": { + "includes": [ + "src/**/*.ts", + "tests/**/*.ts", + "scripts/**/*.ts", + "docs-site/**/*.ts", + "vite.config.ts", + "biome.json", + "!src/game/kitDifficulty.generated.ts" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 140 + }, + "javascript": { + "formatter": { "quoteStyle": "single", "semicolons": "always", "trailingCommas": "es5" } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "suspicious": { "noExplicitAny": "error", "noEmptyBlockStatements": "error" }, + "style": { "noNonNullAssertion": "error" } + } + }, + "overrides": [{ "includes": ["tests/**"], "linter": { "rules": { "style": { "noNonNullAssertion": "off" } } } }], + "assist": { "enabled": false } +} diff --git a/client/bun.lock b/client/bun.lock index 79ae9a4..911772e 100644 --- a/client/bun.lock +++ b/client/bun.lock @@ -8,8 +8,10 @@ "@stellar/stellar-sdk": "^17.2.0", }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/bun": "^1.4.2", "axe-core": "^4.14.0", + "marked": "^18.1.0", "puppeteer-core": "^25.12.0", "typescript": "^5.6.0", "vite": "^5.4.0", @@ -17,6 +19,24 @@ }, }, "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -217,6 +237,8 @@ "is-retry-allowed": ["is-retry-allowed@3.0.0", "", {}, "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A=="], + "marked": ["marked@18.1.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/client/docs-site/diagrams.ts b/client/docs-site/diagrams.ts new file mode 100644 index 0000000..f38ccb7 --- /dev/null +++ b/client/docs-site/diagrams.ts @@ -0,0 +1,43 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DIAGRAMS_DIR } from './manifest'; +import { REPO_ROOT } from './sources'; + +export interface Diagram { + /** File name without extension, also the anchor on the diagrams page. */ + stem: string; + file: string; + title: string; + description: string; + bytes: Uint8Array; +} + +function tagText(svg: string, tag: 'title' | 'desc'): string { + const match = new RegExp(`<${tag}\\s+id="[^"]*">([\\s\\S]*?)`).exec(svg); + return match?.[1]?.trim() ?? ''; +} + +function decodeEntities(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +/** Read every diagram. Fails when one has no title or description for its alt text. */ +export function loadDiagrams(): Diagram[] { + const dir = join(REPO_ROOT, DIAGRAMS_DIR); + return readdirSync(dir) + .filter((name) => name.endsWith('.svg')) + .sort() + .map((name) => { + const bytes = readFileSync(join(dir, name)); + const svg = bytes.toString('utf8'); + const title = decodeEntities(tagText(svg, 'title')); + const description = decodeEntities(tagText(svg, 'desc')); + if (!title || !description) throw new Error(`docs: ${DIAGRAMS_DIR}/${name} has no title or desc for alt text`); + return { stem: name.replace(/\.svg$/, ''), file: `${DIAGRAMS_DIR}/${name}`, title, description, bytes }; + }); +} diff --git a/client/docs-site/docs.css b/client/docs-site/docs.css new file mode 100644 index 0000000..637ab75 --- /dev/null +++ b/client/docs-site/docs.css @@ -0,0 +1,275 @@ +/* Deepmark docs. Tokens copied from the game stylesheet, no import from src/. */ +:root { + color-scheme: dark; + --bg: #07080e; + --bg-raise: #0c101a; + --bg-code: #0a0d1a; + --line: rgba(232, 224, 208, 0.16); + --text: #f4efe6; + --muted: #a3988c; + --gold: #e7c36a; + --gold-deep: #6b5520; + --cyan: #7ef0e0; + --nav-w: 15.5rem; + --body-font: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; + --ui-font: "Avenir Next", "Segoe UI", system-ui, sans-serif; + --mono-font: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 4.5rem; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } + +body { + margin: 0; + background: + radial-gradient(ellipse at 50% -10%, rgba(231, 195, 106, 0.08), transparent 55%), + var(--bg); + color: var(--text); + font: 1.0625rem/1.65 var(--body-font); + -webkit-text-size-adjust: 100%; +} + +a { color: var(--gold); text-underline-offset: 0.18em; } +a:hover { color: #f6dc9b; } +a:focus-visible, label:focus-visible, .table-wrap:focus-visible, main:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 2px; +} +main:focus { outline: none; } + +.skip { + position: absolute; + left: 0.75rem; + top: -4rem; + z-index: 10; + padding: 0.6rem 1rem; + background: var(--gold); + color: #17120a; + border-radius: 0.4rem; + font-family: var(--ui-font); + font-weight: 600; +} +.skip:focus { top: 0.75rem; } + +/* Header */ +.site-header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem max(1rem, env(safe-area-inset-left)); + background: rgba(7, 8, 14, 0.94); + border-bottom: 1px solid var(--line); + font-family: var(--ui-font); +} +.brand { + color: var(--text); + text-decoration: none; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + font-size: 0.95rem; +} +.brand span { color: var(--gold); } +.site-links { display: flex; gap: 1.25rem; font-size: 0.9rem; } +.site-links a { color: var(--muted); text-decoration: none; padding: 0.5rem 0; } +.site-links a:hover { color: var(--gold); } + +/* Layout: left rail on desktop, a CSS only disclosure on phones */ +.layout { + display: grid; + grid-template-columns: var(--nav-w) minmax(0, 1fr); + max-width: 76rem; + margin: 0 auto; +} +.nav-toggle { + position: absolute; + opacity: 0; + pointer-events: none; +} +.nav-button { display: none; } + +.side-nav { + position: sticky; + top: 3.4rem; + align-self: start; + max-height: calc(100vh - 3.4rem); + overflow-y: auto; + padding: 1.5rem 1rem 2rem; + border-right: 1px solid var(--line); + font-family: var(--ui-font); + font-size: 0.93rem; +} +.side-nav ul { list-style: none; margin: 0; padding: 0; } +.side-nav a { + display: block; + padding: 0.5rem 0.75rem; + border-radius: 0.4rem; + color: var(--muted); + text-decoration: none; +} +.side-nav a:hover { color: var(--text); background: var(--bg-raise); } +.side-nav a[aria-current="page"] { + color: var(--gold); + background: var(--bg-raise); + box-shadow: inset 2px 0 0 var(--gold); +} + +main { padding: 2rem clamp(1rem, 4vw, 3rem) 3rem; min-width: 0; } +.prose { max-width: 44rem; } + +/* Prose */ +.prose h1, .prose h2, .prose h3, .prose h4 { + font-family: var(--ui-font); + line-height: 1.25; + position: relative; +} +.prose h1 { font-size: clamp(1.9rem, 5vw, 2.6rem); margin: 0 0 1rem; letter-spacing: -0.01em; } +.prose h2 { font-size: 1.5rem; margin: 2.6rem 0 0.8rem; padding-top: 1.2rem; border-top: 1px solid var(--line); color: var(--gold); } +.prose h3 { font-size: 1.15rem; margin: 2rem 0 0.5rem; } +.prose h4 { font-size: 1rem; margin: 1.5rem 0 0.4rem; color: var(--muted); } +.prose h1 + h2, .prose h1 + p + h2 { border-top: 0; } +.anchor { + margin-left: 0.4em; + color: var(--gold-deep); + text-decoration: none; + font-weight: 400; + opacity: 0; +} +h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, .anchor:focus-visible { opacity: 1; } +@media (hover: none) { .anchor { opacity: 0.7; } } + +.prose p, .prose ul, .prose ol { margin: 0 0 1rem; } +.prose li { margin: 0.3rem 0; } +.prose strong { color: #fff8e8; } +.prose hr { border: 0; border-top: 1px solid var(--line); margin: 2rem 0; } +.prose blockquote { + margin: 1rem 0; + padding: 0.2rem 1rem; + border-left: 3px solid var(--gold-deep); + color: var(--muted); +} +.lede { font-size: 1.2rem; color: #e6dfd0; } + +code, pre { font-family: var(--mono-font); font-size: 0.86em; } +:not(pre) > code { + padding: 0.12em 0.4em; + background: var(--bg-code); + border: 1px solid var(--line); + border-radius: 0.3rem; + overflow-wrap: anywhere; +} +pre { + margin: 1rem 0 1.4rem; + padding: 1rem; + overflow-x: auto; + background: var(--bg-code); + border: 1px solid var(--line); + border-radius: 0.5rem; + line-height: 1.5; + tab-size: 2; +} +pre code { padding: 0; background: none; border: 0; } + +.table-wrap { overflow-x: auto; margin: 1rem 0 1.4rem; border: 1px solid var(--line); border-radius: 0.5rem; } +table { border-collapse: collapse; width: 100%; font-size: 0.92rem; font-family: var(--ui-font); } +th, td { padding: 0.55rem 0.8rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); } +th { color: var(--gold); background: var(--bg-raise); white-space: nowrap; } +tr:last-child td { border-bottom: 0; } +.align-right { text-align: right; } +.align-center { text-align: center; } +td code { word-break: break-all; } + +.prose img { display: block; max-width: 100%; height: auto; margin: 1.2rem 0; border: 1px solid var(--line); border-radius: 0.5rem; } +figure { margin: 1rem 0 2rem; } +figcaption { color: var(--muted); font-size: 0.95rem; margin-top: 0.5rem; } +.diagram { overflow-x: auto; border: 1px solid var(--line); border-radius: 0.5rem; background: #0a0d1a; } +.diagram img { min-width: 40rem; width: 100%; margin: 0; border: 0; border-radius: 0; } + +/* Home */ +.cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 0.8rem; margin-top: 1.5rem; } +.cards a { + display: block; + height: 100%; + padding: 0.9rem 1rem; + background: var(--bg-raise); + border: 1px solid var(--line); + border-radius: 0.6rem; + text-decoration: none; + color: var(--muted); + font-family: var(--ui-font); + font-size: 0.9rem; + line-height: 1.45; +} +.cards a:hover { border-color: var(--gold-deep); } +.cards strong { display: block; color: var(--gold); font-size: 1rem; margin-bottom: 0.25rem; } +.button { + display: inline-block; + padding: 0.7rem 1.1rem; + margin: 0 0.5rem 0.5rem 0; + background: var(--gold); + color: #17120a; + border-radius: 0.45rem; + font: 600 0.95rem var(--ui-font); + text-decoration: none; +} +.button:hover { color: #17120a; background: #f6dc9b; } +.button.ghost { background: transparent; color: var(--gold); border: 1px solid var(--gold-deep); } +.button.ghost:hover { color: #f6dc9b; background: var(--bg-raise); } + +/* Previous and next */ +.pager { display: flex; justify-content: space-between; gap: 1rem; max-width: 44rem; margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--line); font-family: var(--ui-font); } +.pager a { + flex: 1 1 0; + max-width: 20rem; + padding: 0.7rem 1rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + color: var(--text); + text-decoration: none; + min-height: 3.2rem; +} +.pager a:hover { border-color: var(--gold-deep); color: var(--gold); } +.pager span { display: block; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } +.pager-next { text-align: right; margin-left: auto; } + +.site-footer { padding: 1.5rem 1rem 2.5rem; border-top: 1px solid var(--line); text-align: center; color: var(--muted); font: 0.88rem/1.6 var(--ui-font); } +.site-footer p { margin: 0.2rem 0; } + +/* Phones */ +@media (max-width: 52rem) { + .layout { display: block; } + .nav-button { + display: block; + margin: 0.75rem 1rem 0; + padding: 0.7rem 1rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg-raise); + color: var(--gold); + font: 600 0.95rem var(--ui-font); + cursor: pointer; + } + .nav-button::after { content: " \25BE"; } + .nav-toggle:checked + .nav-button::after { content: " \25B4"; } + .nav-toggle:focus-visible + .nav-button { outline: 2px solid var(--cyan); outline-offset: 2px; } + .side-nav { display: none; position: static; max-height: none; border-right: 0; border-bottom: 1px solid var(--line); padding: 0.5rem 1rem 1rem; } + .nav-toggle:checked ~ .side-nav { display: block; } + .side-nav a { padding: 0.7rem 0.75rem; } + .site-links { gap: 0.9rem; } + .pager { flex-direction: column; } + .pager a { max-width: none; } +} + +/* Video cards: a table of linked thumbnails with equal columns. */ +table:has(td img) { table-layout: fixed; } +table:has(td img) thead:has(th:empty) { display: none; } +table:has(td img) td { text-align: center; } +table:has(td img) td img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 0.35rem; } +@media (max-width: 640px) { + table:has(td img) td { padding: 0.5rem; font-size: 0.8rem; } +} diff --git a/client/docs-site/html.ts b/client/docs-site/html.ts new file mode 100644 index 0000000..fcea65d --- /dev/null +++ b/client/docs-site/html.ts @@ -0,0 +1,12 @@ +export function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** GitHub style heading slug, so anchors in the markdown keep working. */ +export function githubSlug(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .replace(/\s/g, '-'); +} diff --git a/client/docs-site/links.ts b/client/docs-site/links.ts new file mode 100644 index 0000000..1162b53 --- /dev/null +++ b/client/docs-site/links.ts @@ -0,0 +1,91 @@ +import { existsSync, statSync } from 'node:fs'; +import { posix, join } from 'node:path'; +import { DIAGRAMS_DIR, GITHUB_BLOB, GITHUB_TREE, PAGES, pageUrl } from './manifest'; +import { REPO_ROOT } from './sources'; + +/** Where a heading of a source file ended up in the site. */ +export interface AnchorTarget { + slug: string; + id: string; + /** The heading was left out of the page, so link to the page itself. */ + dropped: boolean; +} + +export interface LinkContext { + anchors: Map; + /** Slugs of the pages that exist, with the diagram stems of the diagrams page. */ + diagramStems: Set; + /** Images found while rendering: output name to repository path. */ + images: Map; +} + +const SCHEME = /^[a-z][a-z0-9+.-]*:/i; + +export function anchorKey(file: string, slug: string): string { + return `${file}#${slug}`; +} + +/** The first page that renders a given source file. */ +function pageForFile(file: string): string | undefined { + return PAGES.find((page) => page.parts.some((part) => part.file === file))?.slug; +} + +function repoPath(fromFile: string, target: string): string { + const joined = posix.normalize(posix.join(posix.dirname(fromFile), target)); + if (joined.startsWith('..')) throw new Error(`docs: link "${target}" in ${fromFile} leaves the repository`); + return joined; +} + +function isDirectory(path: string): boolean { + return statSync(join(REPO_ROOT, path)).isDirectory(); +} + +function fail(fromFile: string, href: string, why: string): never { + throw new Error(`docs: broken link "${href}" in ${fromFile}: ${why}`); +} + +/** + * Turn a link written for GitHub into one that works on the site. + * External links pass through, links between pages become docs routes, + * links to other repository files become GitHub URLs. Anything else fails. + */ +export function resolveHref(href: string, fromFile: string, currentSlug: string, ctx: LinkContext): string { + if (SCHEME.test(href)) return href; + const hashAt = href.indexOf('#'); + const path = hashAt === -1 ? href : href.slice(0, hashAt); + const hash = hashAt === -1 ? '' : href.slice(hashAt + 1); + const file = path === '' ? fromFile : repoPath(fromFile, decodeURI(path)); + + if (file.startsWith(`${DIAGRAMS_DIR}/`) && file.endsWith('.svg')) { + const stem = posix.basename(file, '.svg'); + if (!ctx.diagramStems.has(stem)) fail(fromFile, href, 'no such diagram'); + return `${pageUrl('diagrams')}#${stem}`; + } + + const pageSlug = pageForFile(file); + if (pageSlug) { + if (!hash) return path === '' ? '#' : pageUrl(pageSlug); + const target = ctx.anchors.get(anchorKey(file, hash)); + if (!target) fail(fromFile, href, `no heading "${hash}" in ${file}`); + if (target.dropped) return target.slug === currentSlug ? '#' : pageUrl(target.slug); + return target.slug === currentSlug ? `#${target.id}` : `${pageUrl(target.slug)}#${target.id}`; + } + + if (!existsSync(join(REPO_ROOT, file))) fail(fromFile, href, `${file} does not exist`); + return `${isDirectory(file) ? GITHUB_TREE : GITHUB_BLOB}/${file}`; +} + +/** Resolve an image path, register it for copying, and return its site URL. */ +export function resolveImage(href: string, fromFile: string, ctx: LinkContext): string { + if (SCHEME.test(href)) return href; + const file = repoPath(fromFile, decodeURI(href)); + const full = join(REPO_ROOT, file); + if (!existsSync(full) || !statSync(full).isFile()) { + throw new Error(`docs: missing image "${href}" in ${fromFile}`); + } + const name = posix.basename(file); + const known = ctx.images.get(name); + if (known && known !== file) throw new Error(`docs: two images share the name ${name}: ${known} and ${file}`); + ctx.images.set(name, file); + return `/docs/img/${name}`; +} diff --git a/client/docs-site/manifest.ts b/client/docs-site/manifest.ts new file mode 100644 index 0000000..dbbf17e --- /dev/null +++ b/client/docs-site/manifest.ts @@ -0,0 +1,131 @@ +/** Where the docs site lives and which repository files feed each page. */ + +export const SITE_ORIGIN = 'https://deepmark-dun.vercel.app'; +export const DOCS_BASE = '/docs'; +export const GITHUB_REPO = 'https://github.com/ZephyRoute/deepmark'; +export const GITHUB_BLOB = `${GITHUB_REPO}/blob/main`; +export const GITHUB_TREE = `${GITHUB_REPO}/tree/main`; +export const DIAGRAMS_DIR = 'diagrams/out/svg'; +export const OG_IMAGE_SOURCE = 'docs/media/poster.jpg'; + +/** One slice of a repository markdown file, read in place at build time. */ +export interface SourcePart { + /** Path from the repository root. */ + file: string; + /** Take only the H2 section with this exact title. */ + heading?: string; + /** Take only the text between the H1 and the first H2. */ + intro?: boolean; + /** Leave out the section heading itself (the page title replaces it). */ + dropHeading?: boolean; +} + +export interface DocPage { + slug: string; + title: string; + description: string; + parts: SourcePart[]; + /** The page is generated from the diagram files instead of markdown. */ + generated?: 'diagrams'; +} + +const README = 'README.md'; + +export const PAGES: DocPage[] = [ + { + slug: 'overview', + title: 'Overview', + description: 'What Deepmark is, how to play it in a minute, and why the game uses Stellar.', + parts: [ + { file: README, intro: true }, + { file: README, heading: 'Watch' }, + { file: README, heading: 'Play in one minute' }, + { file: README, heading: 'Why Stellar' }, + { file: README, heading: 'Check it yourself' }, + { file: README, heading: 'Where things are' }, + ], + }, + { + slug: 'how-it-works', + title: 'How it works', + description: 'What lives on chain, what stays in the browser, and why a run is only two transactions.', + parts: [{ file: 'docs/architecture/onchain-boundary.md' }], + }, + { + slug: 'diagrams', + title: 'Diagrams', + description: 'Seven diagrams of the system: context, one run, replay verification, relic trust, client, data and deployment.', + parts: [], + generated: 'diagrams', + }, + { + slug: 'verify', + title: 'Verify a run', + description: 'Replay a finished run yourself in 60 seconds with one read only command and no wallet.', + parts: [{ file: README, heading: 'Verify a run in 60 seconds', dropHeading: true }], + }, + { + slug: 'contracts', + title: 'Contracts and links', + description: 'Every testnet contract, asset and setup transaction, each linked to the Stellar explorer.', + parts: [{ file: README, heading: 'On testnet', dropHeading: true }, { file: 'docs/deployments.md' }], + }, + { + slug: 'contract-api', + title: 'Contract reference', + description: 'The public contract API, the action encoding, the trust model and the storage layout.', + parts: [{ file: 'contracts/deepmark-contract/README.md' }], + }, + { + slug: 'threat-model', + title: 'Threat model', + description: 'The attacks considered, what stops each one, and what is not covered.', + parts: [{ file: 'docs/threat-model.md' }], + }, + { + slug: 'delvers', + title: 'Delvers and the NFT standard', + description: 'How delvers compare with the current Stellar standard for non fungible tokens.', + parts: [{ file: 'docs/architecture/delvers-and-nft-standard.md' }], + }, + { + slug: 'costs', + title: 'Costs', + description: 'Fees, rent and compute measured from real testnet transactions.', + parts: [{ file: README, heading: 'Measured costs', dropHeading: true }], + }, + { + slug: 'limitations', + title: 'Limitations', + description: 'What the project does not do and where its claims stop.', + parts: [{ file: README, heading: 'Limitations', dropHeading: true }], + }, + { + slug: 'accessibility', + title: 'Accessibility', + description: 'How accessibility is checked, what works, and the known gaps.', + parts: [{ file: 'docs/accessibility.md' }], + }, + { + slug: 'performance', + title: 'Performance', + description: 'How performance is measured, the results and the budgets the build must keep.', + parts: [{ file: 'docs/performance.md' }], + }, + { + slug: 'roadmap', + title: 'Roadmap', + description: 'Where Deepmark is headed, tied to the limits of the game today.', + parts: [{ file: 'docs/roadmap.md' }], + }, + { + slug: 'business-model', + title: 'Business model', + description: 'How the game could be funded without a token or anything pay to win.', + parts: [{ file: 'docs/business-model.md' }], + }, +]; + +export function pageUrl(slug: string): string { + return `${DOCS_BASE}/${slug}/`; +} diff --git a/client/docs-site/plugin.ts b/client/docs-site/plugin.ts new file mode 100644 index 0000000..b931c2f --- /dev/null +++ b/client/docs-site/plugin.ts @@ -0,0 +1,52 @@ +import type { Plugin } from 'vite'; +import { buildSite, type SiteFiles } from './site'; + +const CONTENT_TYPES: Record = { + html: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', + svg: 'image/svg+xml', + jpg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', +}; + +/** `/docs` and `/docs/page` become `/docs/` and `/docs/page/`, like Vercel does for a directory. */ +function withTrailingSlash(url: string): string { + const [path = '', query] = url.split('?'); + if (!/^\/docs(\/[^/.]+)?$/.test(path)) return url; + return query ? `${path}/?${query}` : `${path}/`; +} + +export function docsSite(): Plugin { + let cached: SiteFiles | undefined; + return { + name: 'deepmark-docs-site', + buildStart() { + cached = buildSite(); + }, + generateBundle() { + for (const [fileName, source] of cached ?? buildSite()) { + this.emitFile({ type: 'asset', fileName, source }); + } + }, + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = withTrailingSlash(req.url ?? ''); + const path = url.split('?')[0] ?? ''; + if (!path.startsWith('/docs')) return next(); + const key = path.endsWith('/') ? `${path.slice(1)}index.html` : path.slice(1); + const content = buildSite().get(key); + if (content === undefined) return next(); + res.setHeader('Content-Type', CONTENT_TYPES[key.split('.').pop() ?? ''] ?? 'application/octet-stream'); + res.end(content); + }); + }, + configurePreviewServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url) req.url = withTrailingSlash(req.url); + next(); + }); + }, + }; +} diff --git a/client/docs-site/render.ts b/client/docs-site/render.ts new file mode 100644 index 0000000..acdd916 --- /dev/null +++ b/client/docs-site/render.ts @@ -0,0 +1,107 @@ +import { Marked, type Token, type Tokens } from 'marked'; +import { escapeHtml, githubSlug } from './html'; +import { anchorKey, resolveHref, resolveImage, type AnchorTarget, type LinkContext } from './links'; +import { extractPart } from './sources'; +import type { DocPage } from './manifest'; + +/** A heading token that remembers the id it was given on its page. */ +type DocHeading = Tokens.Heading & { docId?: string }; + +interface LexedPart { + file: string; + tokens: Token[]; +} + +export interface LexedPage { + page: DocPage; + parts: LexedPart[]; +} + +const marked = new Marked({ gfm: true }); + +function alignClass(align: 'left' | 'right' | 'center' | null): string { + return align === 'right' || align === 'center' ? ` class="align-${align}"` : ''; +} + +function plainText(tokens: Token[] | undefined, fallback: string): string { + if (!tokens || tokens.length === 0) return fallback; + return tokens + .map((t) => { + const inner = (t as { tokens?: Token[] }).tokens; + return inner ? plainText(inner, '') : ((t as { text?: string }).text ?? ''); + }) + .join(''); +} + +/** Phase one: lex every part, give headings page unique ids, record where each heading went. */ +export function lexPage(page: DocPage, anchors: Map): LexedPage { + const used = new Map(); + const parts: LexedPart[] = []; + page.parts.forEach((part) => { + const extracted = extractPart(part); + const tokens = marked.lexer(extracted.markdown); + marked.walkTokens(tokens, (token) => { + if (token.type !== 'heading') return; + const heading = token as DocHeading; + if (extracted.shiftDepth) heading.depth = Math.max(2, heading.depth - 1); + const base = githubSlug(plainText(heading.tokens, heading.text)) || 'section'; + const seen = used.get(base) ?? 0; + used.set(base, seen + 1); + heading.docId = seen === 0 ? base : `${base}-${seen}`; + const key = anchorKey(part.file, base); + if (!anchors.has(key)) anchors.set(key, { slug: page.slug, id: heading.docId, dropped: false }); + }); + if (part.heading) { + const key = anchorKey(part.file, githubSlug(part.heading)); + const existing = anchors.get(key); + if (part.dropHeading || !existing) { + anchors.set(key, { slug: page.slug, id: githubSlug(part.heading), dropped: Boolean(part.dropHeading) }); + } + } + parts.push({ file: part.file, tokens }); + }); + return { page, parts }; +} + +/** Phase two: rewrite links and images, then render to HTML. */ +export function renderPage(lexed: LexedPage, ctx: LinkContext): string { + const renderer = new Marked({ + gfm: true, + renderer: { + heading(token) { + const { depth, tokens, docId } = token as DocHeading; + const text = this.parser.parseInline(tokens); + const id = docId ?? ''; + return `${text}#\n`; + }, + link({ href, title, tokens }) { + const external = /^https?:/i.test(href); + const attrs = `${title ? ` title="${escapeHtml(title)}"` : ''}${external ? ' rel="noopener"' : ''}`; + return `${this.parser.parseInline(tokens)}`; + }, + image({ href, title, text }) { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''; + return `${escapeHtml(text)}`; + }, + table(token) { + const header = token.header.map((cell) => `${this.parser.parseInline(cell.tokens)}`).join(''); + const rows = token.rows + .map( + (row) => `${row.map((cell) => `${this.parser.parseInline(cell.tokens)}`).join('')}` + ) + .join('\n'); + return `
${header}${rows}
\n`; + }, + }, + }); + + return lexed.parts + .map((part) => { + renderer.walkTokens(part.tokens, (token) => { + if (token.type === 'link') token.href = resolveHref(token.href, part.file, lexed.page.slug, ctx); + if (token.type === 'image') token.href = resolveImage(token.href, part.file, ctx); + }); + return renderer.parser(part.tokens); + }) + .join('\n'); +} diff --git a/client/docs-site/site.ts b/client/docs-site/site.ts new file mode 100644 index 0000000..50a5b08 --- /dev/null +++ b/client/docs-site/site.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadDiagrams, type Diagram } from './diagrams'; +import { escapeHtml } from './html'; +import type { AnchorTarget, LinkContext } from './links'; +import { DIAGRAMS_DIR, DOCS_BASE, OG_IMAGE_SOURCE, PAGES, SITE_ORIGIN, pageUrl } from './manifest'; +import { lexPage, renderPage } from './render'; +import { REPO_ROOT } from './sources'; +import { canonicalFor, renderShell, type Shell } from './template'; + +export type SiteFiles = Map; + +const EM_DASH = String.fromCharCode(0x2014); +const OG_IMAGE_NAME = 'og-poster.jpg'; + +function diagramsBody(diagrams: Diagram[]): string { + const intro = + '

Seven diagrams, drawn from one source each and checked into the repository. Select a diagram to open the full size file.

'; + const sections = diagrams + .map( + (d) => `
+

${escapeHtml(d.title)}#

+
+
${escapeHtml(`${d.title}. ${d.description}`)}
+
${escapeHtml(d.description)}
+
+
` + ) + .join('\n'); + return `${intro}\n${sections}`; +} + +function indexBody(): string { + const cards = PAGES.map( + (page) => + `
  • ${escapeHtml(page.title)}${escapeHtml(page.description)}
  • ` + ).join('\n'); + return `

    Deepmark is a turn based dungeon crawler where a Stellar contract replays every run, so a result or a relic cannot be claimed without playing for it. These pages are built from the markdown in the repository.

    +

    Play the game Verify a run in 60 seconds

    +
      +${cards} +
    `; +} + +/** Build every file of the docs site, relative to the site root (for example docs/overview/index.html). */ +export function buildSite(): SiteFiles { + const files: SiteFiles = new Map(); + const diagrams = loadDiagrams(); + const anchors = new Map(); + const ctx: LinkContext = { + anchors, + diagramStems: new Set(diagrams.map((d) => d.stem)), + images: new Map(diagrams.map((d) => [`${d.stem}.svg`, d.file])), + }; + + const css = readFileSync(join(import.meta.dirname, 'docs.css')); + const cssHash = createHash('sha256').update(css).digest('hex').slice(0, 10); + const cssPath = `${DOCS_BASE}/assets/docs-${cssHash}.css`; + files.set(cssPath.slice(1), css); + const ogImage = `${SITE_ORIGIN}${DOCS_BASE}/img/${OG_IMAGE_NAME}`; + files.set(`docs/img/${OG_IMAGE_NAME}`, readFileSync(join(REPO_ROOT, OG_IMAGE_SOURCE))); + + const lexed = PAGES.map((page) => ({ page, lexed: page.generated ? undefined : lexPage(page, anchors) })); + + const emit = (shell: Shell): void => { + files.set(`${shell.path.slice(1)}index.html`, renderShell(shell)); + }; + + emit({ + path: `${DOCS_BASE}/`, + canonical: canonicalFor(`${DOCS_BASE}/`), + title: 'Documentation', + heading: 'Deepmark documentation', + description: + 'Documentation for Deepmark, a dungeon crawler on Stellar where the contract replays every run: how it works, how to verify a run, contracts, threat model and roadmap.', + bodyHtml: indexBody(), + cssHref: cssPath, + ogImage, + next: PAGES[0], + }); + + lexed.forEach(({ page, lexed: lex }, i) => { + const bodyHtml = lex ? renderPage(lex, ctx) : diagramsBody(diagrams); + const path = pageUrl(page.slug); + emit({ + path, + canonical: canonicalFor(path), + title: page.title, + heading: page.title, + description: page.description, + bodyHtml, + cssHref: cssPath, + ogImage, + currentSlug: page.slug, + prev: PAGES[i - 1], + next: PAGES[i + 1], + sourceFile: page.generated ? DIAGRAMS_DIR : page.parts[0]?.file, + }); + }); + + for (const d of diagrams) files.set(`docs/img/${d.stem}.svg`, d.bytes); + for (const [name, file] of ctx.images) { + if (!files.has(`docs/img/${name}`)) files.set(`docs/img/${name}`, readFileSync(join(REPO_ROOT, file))); + } + + for (const [path, content] of files) { + if (typeof content === 'string' && content.includes(EM_DASH)) { + throw new Error(`docs: ${path} contains an em dash, fix the source markdown`); + } + } + return files; +} diff --git a/client/docs-site/sources.ts b/client/docs-site/sources.ts new file mode 100644 index 0000000..4fac090 --- /dev/null +++ b/client/docs-site/sources.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { SourcePart } from './manifest'; + +/** The repository root, one level above the client. */ +export const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +export interface ExtractedPart { + markdown: string; + /** Headings below the dropped section heading move up one level. */ + shiftDepth: boolean; +} + +interface HeadingLine { + index: number; + depth: number; + text: string; +} + +const FENCE = /^\s*(```|~~~)/; +const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/; + +function headingLines(lines: string[]): HeadingLine[] { + const found: HeadingLine[] = []; + let inFence = false; + lines.forEach((line, index) => { + if (FENCE.test(line)) { + inFence = !inFence; + return; + } + if (inFence) return; + const match = HEADING.exec(line); + if (match?.[1] && match[2]) found.push({ index, depth: match[1].length, text: match[2] }); + }); + return found; +} + +export function readRepoFile(file: string): string { + return readFileSync(join(REPO_ROOT, file), 'utf8'); +} + +/** Cut the requested slice out of a source file. Throws when a heading is missing. */ +export function extractPart(part: SourcePart): ExtractedPart { + const lines = readRepoFile(part.file).split('\n'); + const headings = headingLines(lines); + const h1 = headings.find((h) => h.depth === 1); + const afterTitle = h1 ? h1.index + 1 : 0; + + if (part.heading) { + const start = headings.find((h) => h.depth === 2 && h.text === part.heading); + if (!start) throw new Error(`docs: heading "${part.heading}" not found in ${part.file}`); + const next = headings.find((h) => h.index > start.index && h.depth <= 2); + const end = next ? next.index : lines.length; + const from = part.dropHeading ? start.index + 1 : start.index; + return { markdown: lines.slice(from, end).join('\n'), shiftDepth: Boolean(part.dropHeading) }; + } + + const firstH2 = headings.find((h) => h.depth === 2); + const end = part.intro && firstH2 ? firstH2.index : lines.length; + return { markdown: lines.slice(afterTitle, end).join('\n'), shiftDepth: false }; +} diff --git a/client/docs-site/template.ts b/client/docs-site/template.ts new file mode 100644 index 0000000..b69fee0 --- /dev/null +++ b/client/docs-site/template.ts @@ -0,0 +1,105 @@ +import { DOCS_BASE, GITHUB_BLOB, GITHUB_REPO, PAGES, SITE_ORIGIN, pageUrl, type DocPage } from './manifest'; +import { escapeHtml } from './html'; + +export interface Shell { + /** Route path with a leading slash, for example /docs/overview/. */ + path: string; + /** The URL search engines should index. */ + canonical: string; + title: string; + description: string; + heading: string; + bodyHtml: string; + cssHref: string; + ogImage: string; + /** The nav entry to mark as current, or undefined for the index. */ + currentSlug?: string; + prev?: DocPage; + next?: DocPage; + /** Repository file the page comes from, for the edit link. */ + sourceFile?: string; +} + +const SITE_NAME = 'Deepmark docs'; + +function navHtml(currentSlug: string | undefined): string { + const items = PAGES.map((page) => { + const current = page.slug === currentSlug ? ' aria-current="page"' : ''; + return `
  • ${escapeHtml(page.title)}
  • `; + }).join('\n'); + const home = currentSlug === undefined ? ' aria-current="page"' : ''; + return ``; +} + +function pagerHtml(prev: DocPage | undefined, next: DocPage | undefined, isHome: boolean): string { + const link = (page: DocPage, rel: 'prev' | 'next', label: string): string => + `${label}${escapeHtml(page.title)}`; + const left = prev + ? link(prev, 'prev', 'Previous') + : isHome + ? '' + : ``; + const right = next ? link(next, 'next', 'Next') : ''; + return ``; +} + +export function renderShell(shell: Shell): string { + const fullTitle = shell.path === `${DOCS_BASE}/` ? `${SITE_NAME}: ${shell.title}` : `${shell.title} | ${SITE_NAME}`; + const description = escapeHtml(shell.description); + const edit = shell.sourceFile + ? `View the source on GitHub` + : `View the repository on GitHub`; + return ` + + + + +${escapeHtml(fullTitle)} + + + + + + + + + + + + + + + + +
    + + + +
    +
    +

    ${escapeHtml(shell.heading)}

    +${shell.bodyHtml} +
    +${pagerHtml(shell.prev, shell.next, shell.currentSlug === undefined)} +
    +
    + + + +`; +} + +export function canonicalFor(path: string): string { + return path === `${DOCS_BASE}/` ? `${SITE_ORIGIN}${DOCS_BASE}` : `${SITE_ORIGIN}${path}`; +} diff --git a/client/index.html b/client/index.html index 2f19f7b..1fe4c1e 100644 --- a/client/index.html +++ b/client/index.html @@ -8,7 +8,7 @@ - + Deepmark diff --git a/client/package.json b/client/package.json index 0b7de03..574d96a 100644 --- a/client/package.json +++ b/client/package.json @@ -15,13 +15,19 @@ "golden": "bun run scripts/golden-vectors.ts", "kits": "bun run scripts/kit-difficulty.ts", "test": "bun test", + "test:coverage": "bun test --coverage", + "lint": "biome lint .", + "format": "biome format --write .", + "format:check": "biome format .", "perf": "bun run scripts/perf.ts", "a11y": "bun run scripts/a11y.ts", "contrast": "bun run scripts/contrast.ts" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/bun": "^1.4.2", "axe-core": "^4.14.0", + "marked": "^18.1.0", "puppeteer-core": "^25.12.0", "typescript": "^5.6.0", "vite": "^5.4.0" diff --git a/client/scripts/a11y.ts b/client/scripts/a11y.ts index cf3c03d..811b8ab 100644 --- a/client/scripts/a11y.ts +++ b/client/scripts/a11y.ts @@ -173,14 +173,24 @@ async function targetFindings(page: Page): Promise { if (hidden || node.classList.contains('sr-only')) continue; if (Math.round(rect.width) < min || Math.round(rect.height) < min) { const label = node.getAttribute('aria-label') ?? node.textContent?.trim().slice(0, 30) ?? ''; - out.push(`${node.tagName.toLowerCase()}.${node.className.toString().split(' ')[0]} "${label}" ${Math.round(rect.width)}x${Math.round(rect.height)}`); + out.push( + `${node.tagName.toLowerCase()}.${node.className.toString().split(' ')[0]} "${label}" ${Math.round(rect.width)}x${Math.round(rect.height)}` + ); } } return out; }, MIN_TARGET_PX); return small.length === 0 ? [] - : [{ kind: 'target', rule: 'target-size-44', impact: 'serious', detail: `Controls under ${MIN_TARGET_PX} by ${MIN_TARGET_PX} px`, nodes: small }]; + : [ + { + kind: 'target', + rule: 'target-size-44', + impact: 'serious', + detail: `Controls under ${MIN_TARGET_PX} by ${MIN_TARGET_PX} px`, + nodes: small, + }, + ]; } /** Animations still looping once the screen has settled. */ @@ -193,7 +203,15 @@ async function motionFindings(page: Page): Promise { ); return looping.length === 0 ? [] - : [{ kind: 'motion', rule: 'reduced-motion-loop', impact: 'serious', detail: 'Animation still looping with reduced motion on', nodes: looping }]; + : [ + { + kind: 'motion', + rule: 'reduced-motion-loop', + impact: 'serious', + detail: 'Animation still looping with reduced motion on', + nodes: looping, + }, + ]; } async function auditScreen(pass: Pass, scenario: Scenario, browser: Awaited>): Promise { @@ -202,7 +220,11 @@ async function auditScreen(pass: Pass, scenario: Scenario, browser: Awaited BLOCKING_IMPACTS.includes(f.impact)).length }; } @@ -215,7 +237,9 @@ const KEY_SETTLE_MS = 250; const describeFocus = (page: Page): Promise => page.evaluate(() => { const a = document.activeElement; - return a ? `${a.tagName.toLowerCase()}.${a.className.toString().split(' ')[0]} "${(a.textContent ?? '').trim().slice(0, 24)}"` : 'nothing'; + return a + ? `${a.tagName.toLowerCase()}.${a.className.toString().split(' ')[0]} "${(a.textContent ?? '').trim().slice(0, 24)}"` + : 'nothing'; }); /** Tabs until a control matching the selector has focus. Returns false when it never does. */ @@ -244,10 +268,16 @@ async function keyboardFindings(browser: Awaited undefined); const opened = await page.evaluate(() => document.querySelector('.panel-title')?.textContent ?? ''); check(opened === 'Your vault', `Enter on the focused Vault item opened "${opened || 'nothing'}" instead of the vault`); - check(await page.evaluate(() => document.activeElement?.classList.contains('panel-title') ?? false), `Focus did not move into the vault screen (on ${await describeFocus(page)})`); + check( + await page.evaluate(() => document.activeElement?.classList.contains('panel-title') ?? false), + `Focus did not move into the vault screen (on ${await describeFocus(page)})` + ); await page.keyboard.press('Escape'); await settle(SHORT_SETTLE_MS); - check(await page.evaluate(() => document.activeElement?.matches('.menu-item.is-primary') ?? false), `Focus did not return to the menu after Escape (on ${await describeFocus(page)})`); + check( + await page.evaluate(() => document.activeElement?.matches('.menu-item.is-primary') ?? false), + `Focus did not return to the menu after Escape (on ${await describeFocus(page)})` + ); } // Forge and descend without touching the pointer: Enter on the menu, Enter inside the name field. @@ -274,7 +304,9 @@ async function keyboardFindings(browser: Awaited { return puppeteer.launch({ diff --git a/client/scripts/budgets.ts b/client/scripts/budgets.ts index 1682792..16dca96 100644 --- a/client/scripts/budgets.ts +++ b/client/scripts/budgets.ts @@ -61,15 +61,38 @@ export function checkBudgets(report: BudgetInput, profile: ProfileName): Violati }; over('Cold load transfer', report.load.bytesKB, BUDGETS.transferKB, ' KB', 'Check for a new or unoptimized asset.'); - over('Cumulative layout shift', report.load.clsScore, BUDGETS.layoutShift, '', 'Reserve space for late content or load styles before first paint.'); + over( + 'Cumulative layout shift', + report.load.clsScore, + BUDGETS.layoutShift, + '', + 'Reserve space for late content or load styles before first paint.' + ); if (report.load.longTasks.count > 0) { - over('Longest task during load', report.load.longTasks.maxMs, longTaskMs, ' ms', 'Split or defer the work that blocks the main thread.'); + over( + 'Longest task during load', + report.load.longTasks.maxMs, + longTaskMs, + ' ms', + 'Split or defer the work that blocks the main thread.' + ); } const sampled = report.frames.find((f) => f.throttle === throttle); if (!sampled) { - out.push({ metric: 'Frame time', actual: 0, limit: BUDGETS.frameMs, message: `No frame sample at ${throttle}x CPU throttle was recorded.` }); + out.push({ + metric: 'Frame time', + actual: 0, + limit: BUDGETS.frameMs, + message: `No frame sample at ${throttle}x CPU throttle was recorded.`, + }); } else { - over(`Frame time ${percentile} at ${throttle}x throttle`, sampled[percentile], BUDGETS.frameMs, ' ms', 'Profile the render loop for per frame allocations.'); + over( + `Frame time ${percentile} at ${throttle}x throttle`, + sampled[percentile], + BUDGETS.frameMs, + ' ms', + 'Profile the render loop for per frame allocations.' + ); } return out; } diff --git a/client/scripts/chain-smoke.ts b/client/scripts/chain-smoke.ts index c19d0a2..d654552 100644 --- a/client/scripts/chain-smoke.ts +++ b/client/scripts/chain-smoke.ts @@ -39,7 +39,7 @@ lap(`account ready ${address.slice(0, 6)}...`); /** Opens runs until the scripted player clears all three floors, then finishes that run. */ async function clearOnce(choice: HeroChoice): Promise<{ heroId: number | undefined; score: number }> { for (let attempt = 1; attempt <= 12; attempt++) { - let ticket; + let ticket: Awaited> | undefined; for (let retry = 0; retry < 4; retry++) { try { ticket = await startRun(keypair, choice); @@ -50,32 +50,38 @@ async function clearOnce(choice: HeroChoice): Promise<{ heroId: number | undefin } } if (!ticket) throw new Error('could not open a run'); - const state = newRun( - { base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank }, - ticket.seed, - ticket.look - ); + const state = newRun({ base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank }, ticket.seed, ticket.look); playBalanced(state); const cleared = state.resultKind === 'extracted' && state.floorNumber === 3; - lap(`attempt ${attempt} seed ${ticket.seed} rank ${ticket.rank}: ${cleared ? 'cleared' : state.resultKind} (${state.actions.length} actions)`); + lap( + `attempt ${attempt} seed ${ticket.seed} rank ${ticket.rank}: ${cleared ? 'cleared' : state.resultKind} (${state.actions.length} actions)` + ); await sleep(Math.ceil(state.actions.length / 16) * 5000 + 6000); const { outcome, hash } = await finishRun(keypair, state.actions); - lap(`finish_run ${hash} extracted=${outcome.extracted} floor=${outcome.floor_reached} score=${outcome.score} hero=${outcome.hero_id ?? 'none'}`); + lap( + `finish_run ${hash} extracted=${outcome.extracted} floor=${outcome.floor_reached} score=${outcome.score} hero=${outcome.hero_id ?? 'none'}` + ); if (cleared) return { heroId: outcome.hero_id, score: outcome.score }; } throw new Error('no clear in 12 attempts'); } -const first = await clearOnce( - freshChoice({ look: { name: 'Smoke Test', emblem: 2, cloak: 3 }, base: 1, weapon: 1, charm: 2 }) -); +const first = await clearOnce(freshChoice({ look: { name: 'Smoke Test', emblem: 2, cloak: 3 }, base: 1, weapon: 1, charm: 2 })); if (first.heroId === undefined) throw new Error('the cleared run did not earn a delver'); const heroes = await fetchHeroes(address); -console.log('vault after the first clear:', heroes.map((h) => ({ id: h.id, name: h.look.name, clears: h.clears }))); +console.log( + 'vault after the first clear:', + heroes.map((h) => ({ id: h.id, name: h.look.name, clears: h.clears })) +); const second = await clearOnce(veteranChoice(first.heroId)); const after = await fetchHeroes(address); -console.log('vault after the veteran clear:', after.map((h) => ({ id: h.id, clears: h.clears })), 'second hero id', second.heroId); +console.log( + 'vault after the veteran clear:', + after.map((h) => ({ id: h.id, clears: h.clears })), + 'second hero id', + second.heroId +); const record = await fetchRecord(address); console.log({ diff --git a/client/scripts/contrast.ts b/client/scripts/contrast.ts index 6607464..da0f9a2 100644 --- a/client/scripts/contrast.ts +++ b/client/scripts/contrast.ts @@ -139,10 +139,13 @@ if (MARKDOWN) { console.log('| Use | Foreground | Surface | Ratio | Needs | Result |'); console.log('|---|---|---|---|---|---|'); for (const row of rows) { - console.log(`| ${row.use} | \`${row.fg}\` | \`${row.bg}\` | ${row.ratio.toFixed(2)} | ${row.min} | ${row.ratio >= row.min ? 'pass' : 'fail'} |`); + console.log( + `| ${row.use} | \`${row.fg}\` | \`${row.bg}\` | ${row.ratio.toFixed(2)} | ${row.min} | ${row.ratio >= row.min ? 'pass' : 'fail'} |` + ); } } else { - for (const row of rows) console.log(`${row.ratio >= row.min ? 'ok ' : 'FAIL'} ${row.ratio.toFixed(2).padStart(5)} (needs ${row.min}) ${row.use}`); + for (const row of rows) + console.log(`${row.ratio >= row.min ? 'ok ' : 'FAIL'} ${row.ratio.toFixed(2).padStart(5)} (needs ${row.min}) ${row.use}`); console.log(failing.length === 0 ? 'CONTRAST: all pairs pass' : `CONTRAST: ${failing.length} pairs below threshold`); } process.exit(failing.length === 0 ? 0 : 1); diff --git a/client/scripts/golden-vectors.ts b/client/scripts/golden-vectors.ts index 67bbccb..35f5d1d 100644 --- a/client/scripts/golden-vectors.ts +++ b/client/scripts/golden-vectors.ts @@ -98,10 +98,7 @@ function playOne(g: Rng): { line: string } | null { } else { const relic = state.relics.find((r) => !r.collected); const goal: Point = habits.wantsRelic && relic ? relic : state.layout.stairsPos; - const code = - stepToward(state, goal, habits.avoidsFights) ?? - stepToward(state, goal, false) ?? - (randInt(g, 0, 3) as ActionCode); + const code = stepToward(state, goal, habits.avoidsFights) ?? stepToward(state, goal, false) ?? (randInt(g, 0, 3) as ActionCode); applyAction(state, code); } kinds.push(...relicKinds(state)); diff --git a/client/scripts/kit-difficulty.ts b/client/scripts/kit-difficulty.ts index d947f8b..5b24970 100644 --- a/client/scripts/kit-difficulty.ts +++ b/client/scripts/kit-difficulty.ts @@ -30,9 +30,7 @@ function clearPercent(base: number, weapon: number, charm: number, seeds: readon function main(): void { const stream = mulberry32(SEED_STREAM); const seeds = Array.from({ length: RUNS_PER_KIT }, () => Math.floor(stream() * 0xffffffff)); - const table = HERO_ORDER.map((_, base) => - WEAPONS.map((_, weapon) => CHARMS.map((_, charm) => clearPercent(base, weapon, charm, seeds))) - ); + const table = HERO_ORDER.map((_, base) => WEAPONS.map((_, weapon) => CHARMS.map((_, charm) => clearPercent(base, weapon, charm, seeds)))); const rows = table.map((weapons) => ` [${weapons.map((charms) => `[${charms.join(', ')}]`).join(', ')}],`); console.log( [ diff --git a/client/scripts/perf.ts b/client/scripts/perf.ts index 8523021..fe8d058 100644 --- a/client/scripts/perf.ts +++ b/client/scripts/perf.ts @@ -81,12 +81,15 @@ async function coldLoad(page: Page): Promise { new PerformanceObserver((l) => { for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') perf.fcp = e.startTime; }).observe({ type: 'paint', buffered: true }); - (w.__shifts as string[] | undefined) ?? (w.__shifts = []); + if (!w.__shifts) w.__shifts = []; new PerformanceObserver((l) => { for (const e of l.getEntries() as unknown as { value: number; hadRecentInput: boolean; sources?: { node?: Element }[] }[]) { if (e.hadRecentInput) continue; perf.cls += e.value; - const names = (e.sources ?? []).map((src) => `${src.node?.nodeName ?? '?'}.${(src.node as HTMLElement | undefined)?.className ?? ''}#${(src.node as HTMLElement | undefined)?.id ?? ''}`); + const names = (e.sources ?? []).map( + (src) => + `${src.node?.nodeName ?? '?'}.${(src.node as HTMLElement | undefined)?.className ?? ''}#${(src.node as HTMLElement | undefined)?.id ?? ''}` + ); (w.__shifts as string[]).push(`${e.value.toFixed(3)} ${names.join(' | ')}`); } }).observe({ type: 'layout-shift', buffered: true }); @@ -223,7 +226,13 @@ try { violations = checkBudgets({ load, frames }, PROFILE); if (JSON_OUTPUT) { - console.log(JSON.stringify({ url: URL_UNDER_TEST, profile: PROFILE, budgets: { ...BUDGETS, ...PROFILES[PROFILE] }, load, frames, violations }, null, 2)); + console.log( + JSON.stringify( + { url: URL_UNDER_TEST, profile: PROFILE, budgets: { ...BUDGETS, ...PROFILES[PROFILE] }, load, frames, violations }, + null, + 2 + ) + ); } if (violations.length === 0) log('PERF BUDGETS: all within budget'); else for (const v of violations) console.error(`PERF BUDGET EXCEEDED: ${v.message}`); diff --git a/client/src/app/actions.ts b/client/src/app/actions.ts new file mode 100644 index 0000000..606c462 --- /dev/null +++ b/client/src/app/actions.ts @@ -0,0 +1,160 @@ +import { ActionCode, applyAction, drainEvents, type RunState } from '../game/state'; +import { saveRun } from '../game/savedRun'; +import { resultGuardActive } from '../input/guard'; +import { onAction, type Action } from '../input/keyboard'; +import { face } from '../render/canvas'; +import { isMuted, sfxStep, sfxUi, toggleMuted } from '../render/audio'; +import { noteHintInput, resetHints } from '../render/hints'; +import { + copyRecoveryCode, + loadBoard, + openLeaderboard, + openVault, + checkRestoreCode, + confirmRestore, + cancelRestore, + startRestore, + verifyOnChain, +} from './onchain'; +import { endExhaustedRun, leaveResult, openCreator } from './run'; +import { copyChallengeLink, shareResult } from './share'; +import { handleEvent } from './events'; +import { session } from './session'; + +const DIRS: Partial> = { + up: [0, -1], + down: [0, 1], + left: [-1, 0], + right: [1, 0], +}; + +const MOVE_CODES = { + up: ActionCode.Up, + down: ActionCode.Down, + left: ActionCode.Left, + right: ActionCode.Right, +} as const; + +export function syncMuteButton(): void { + const button = document.getElementById('mute'); + if (!button) return; + const muted = isMuted(); + button.textContent = muted ? 'Sound off' : 'Sound on'; + button.setAttribute('aria-pressed', muted ? 'true' : 'false'); +} + +function handleStatsAction(action: Action): void { + if (action === 'cancel' && session.restoreStep !== 'closed') { + // Escape backs out of the restore step first, the card stays open. + cancelRestore(); + return; + } + if (action === 'stats' || action === 'cancel') { + session.showStats = false; + session.recoveryNote = ''; + cancelRestore(); + } + if (action === 'copyRecovery') void copyRecoveryCode(); + if (action === 'restoreRecovery') startRestore(); + if (action === 'restoreCheck') void checkRestoreCode(); + if (action === 'restoreConfirm') void confirmRestore(); + if (action === 'restoreCancel') cancelRestore(); + if (action === 'replayHints') resetHints(); +} + +function handleLeaderboardAction(action: Action): void { + if (action === 'leaderboard' || action === 'cancel') session.showLeaderboard = false; + if (action === 'retryBoard') void loadBoard(); + if (action === 'tabFresh' || action === 'tabVeteran') { + session.boardTab = action === 'tabVeteran' ? 'veteran' : 'fresh'; + void loadBoard(); + } +} + +function handleVaultAction(action: Action): void { + if (action === 'vault' || action === 'cancel') session.showVault = false; + if (action === 'retryVault') void openVault(); +} + +function handleTitleAction(action: Action): void { + if (action === 'confirm') openCreator(); + if (action === 'vault') void openVault(); + if (action === 'stats') session.showStats = true; + if (action === 'leaderboard') void openLeaderboard(); +} + +function handleResultAction(action: Action, resultRun: RunState): void { + if (action === 'confirm' && !resultGuardActive(session.resultShownAt, performance.now())) leaveResult(); + if (action === 'again') { + leaveResult(); + openCreator(); + } + if (action === 'retryVerify' && session.failedRun) void verifyOnChain(session.failedRun); + if (action === 'shareSave' || action === 'shareCopy') void shareResult(resultRun, action === 'shareCopy' ? 'copy' : 'save'); + if (action === 'shareChallenge') void copyChallengeLink(resultRun); + if (action === 'stats') session.showStats = true; + if (action === 'vault') { + leaveResult(); + void openVault(); + } +} + +/** Applies one input to a live run, then settles the consequences (save, exhaustion, events). */ +function handlePlayAction(run: RunState, action: Action): void { + const actionsBefore = run.actions.length; + if (run.mode === 'combat') { + if (action === 'confirm') applyAction(run, ActionCode.Attack); + if (action === 'cancel') applyAction(run, ActionCode.Retreat); + noteHintInput(action); + } else if (run.mode === 'choice') { + if (action === 'confirm') applyAction(run, ActionCode.Descend); + if (action === 'cancel') applyAction(run, ActionCode.Extract); + noteHintInput(action); + } else { + const dir = DIRS[action]; + if (dir) { + if (dir[0] !== 0) face(dir[0] < 0 ? -1 : 1); + const moved = applyAction(run, MOVE_CODES[action as keyof typeof MOVE_CODES]); + if (moved && run.mode === 'explore') sfxStep(); + noteHintInput(action); + } + } + + if (run.exhausted) endExhaustedRun(run); + if (!session.practice && run.actions.length !== actionsBefore) saveRun(run); + for (const event of drainEvents(run)) handleEvent(event); +} + +/** Routes an input to the screen that owns it, topmost screen first. */ +function dispatch(action: Action): void { + if (action === 'mute') { + toggleMuted(); + syncMuteButton(); + return; + } + if (!session.run) sfxUi(); + if (session.showStats) { + handleStatsAction(action); + } else if (session.showLeaderboard) { + handleLeaderboardAction(action); + } else if (session.starting) { + // The descent scene owns the screen until the run opens. + } else if (session.bossIntro) { + session.bossIntro.skip(); + } else if (session.showCreator) { + if (action === 'cancel') session.showCreator = false; + } else if (session.showVault) { + handleVaultAction(action); + } else if (!session.run) { + handleTitleAction(action); + } else if (session.run.mode === 'result') { + handleResultAction(action, session.run); + } else { + handlePlayAction(session.run, action); + } +} + +export function installInput(): void { + syncMuteButton(); + onAction(dispatch); +} diff --git a/client/src/app/canvas.ts b/client/src/app/canvas.ts new file mode 100644 index 0000000..ab5324d --- /dev/null +++ b/client/src/app/canvas.ts @@ -0,0 +1,6 @@ +const element = document.getElementById('game') as HTMLCanvasElement; +const context = element.getContext('2d'); +if (!context) throw new Error('Canvas 2D context unavailable'); + +export const canvas: HTMLCanvasElement = element; +export const ctx: CanvasRenderingContext2D = context; diff --git a/client/src/app/chainQueue.ts b/client/src/app/chainQueue.ts new file mode 100644 index 0000000..2392e7d --- /dev/null +++ b/client/src/app/chainQueue.ts @@ -0,0 +1,37 @@ +/** + * One chain transaction at a time. Two transactions from the same account + * built before the first lands would reuse a sequence number and one of them + * would fail, so starting a run waits for the previous run's verification. + */ +export class ChainQueue { + private tail: Promise = Promise.resolve(); + + /** Runs `job` after every earlier job has settled, whether it succeeded or not. */ + enqueue(job: () => Promise): Promise { + const next = this.tail.then(job, job); + this.tail = next.catch(() => undefined); + return next; + } +} + +export const chainQueue = new ChainQueue(); + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for Stellar')), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} diff --git a/client/src/app/descendHeartbeat.ts b/client/src/app/descendHeartbeat.ts new file mode 100644 index 0000000..641d5c8 --- /dev/null +++ b/client/src/app/descendHeartbeat.ts @@ -0,0 +1,30 @@ +import { sfxHeartbeat } from '../render/audio'; +import { DESCEND_BEAT_DECAY, DESCEND_BEAT_MIN_MS, DESCEND_BEAT_START_MS } from '../constants'; + +/** Hovering Descend on the bank-or-push prompt: the heartbeat quickens the longer it holds. */ +let hovering = false; +let timer: ReturnType | null = null; + +function scheduleBeat(delay: number): void { + timer = setTimeout(() => { + if (!hovering) return; + sfxHeartbeat(); + scheduleBeat(Math.max(DESCEND_BEAT_MIN_MS, delay * DESCEND_BEAT_DECAY)); + }, delay); +} + +export function isDescendHovering(): boolean { + return hovering; +} + +export function onDescendHover(next: boolean): void { + if (next === hovering) return; + hovering = next; + if (next) { + sfxHeartbeat(); + scheduleBeat(DESCEND_BEAT_START_MS); + } else if (timer) { + clearTimeout(timer); + timer = null; + } +} diff --git a/client/src/app/events.ts b/client/src/app/events.ts new file mode 100644 index 0000000..a86ce37 --- /dev/null +++ b/client/src/app/events.ts @@ -0,0 +1,191 @@ +import type { GameEvent } from '../game/state'; +import { GRID_WIDTH, MAX_FLOOR, TILE_SIZE } from '../game/floor'; +import { HEROES, HERO_ORDER } from '../game/heroes'; +import { + sfxBanner, + sfxBossIntro, + sfxDead, + sfxDescend, + sfxEnemyDied, + sfxExtracted, + sfxFloorCleared, + sfxHitEnemy, + sfxHitPlayer, + sfxRelic, + stopAmbience, + startAmbience, +} from '../render/audio'; +import { showBanner } from '../render/banner'; +import { playBossIntro, preloadBossArt } from '../render/bossIntro'; +import { hitStop, punch } from '../render/clock'; +import { flashActor, spawnBurst, spawnPopup, spawnRelicTravel, triggerShake } from '../render/effects'; +import { showHint } from '../render/hints'; +import { playAttack, resetMotion } from '../render/motion'; +import { preloadShareAssets } from '../render/shareCard'; +import { canvas } from './canvas'; +import { FLOOR_NAMES } from './floorNames'; +import { verifyOnChain } from './onchain'; +import { settleLocalProgress } from './run'; +import { session } from './session'; + +/** Popup colours by kind of number. */ +const POPUP_DAMAGE_DEALT = '#e5e7eb'; +const POPUP_DAMAGE_TAKEN = '#ef5350'; +const POPUP_RELIC = '#ffca28'; + +/** Space reserved below the board's bottom edge so a travelling relic never leaves the canvas. */ +const RELIC_TRAVEL_EDGE_MARGIN = 4; +const BOSS_NAME = 'Warden Prime'; + +/** A hit of this size or more freezes the frame a little longer. */ +const HEAVY_HIT = 5; + +type EventOf = Extract; + +function onEnemyHit(event: EventOf<'enemyHit'>): void { + const run = session.run; + if (run) { + playAttack('player', run.player.x, run.player.y); + const struck = run.enemies.findIndex((e) => e.x === event.x && e.y === event.y); + if (struck >= 0) flashActor(`enemy-${struck}`); + } + spawnPopup(event.x, event.y, `-${event.amount}`, POPUP_DAMAGE_DEALT); + spawnBurst(event.x, event.y, 'hit'); + sfxHitEnemy(); + hitStop(event.amount >= HEAVY_HIT ? 90 : 55); + punch(Math.min(1, 0.25 + event.amount * 0.1)); + triggerShake(100 + event.amount * 12, 3 + event.amount); +} + +function onPlayerHit(event: EventOf<'playerHit'>): void { + const run = session.run; + if (run && run.activeEnemyIndex !== null) { + const enemy = run.enemies[run.activeEnemyIndex]; + if (enemy) playAttack(`enemy-${run.activeEnemyIndex}`, enemy.x, enemy.y); + } + spawnPopup(event.x, event.y, `-${event.amount}`, POPUP_DAMAGE_TAKEN); + flashActor('player'); + spawnBurst(event.x, event.y, 'hit'); + sfxHitPlayer(); + hitStop(80); + punch(0.5 + Math.min(0.5, event.amount * 0.1)); + triggerShake(150 + event.amount * 15, 5 + event.amount * 1.5); +} + +function onEnemyDied(event: EventOf<'enemyDied'>): void { + spawnBurst(event.x, event.y, 'kill'); + hitStop(120); + punch(0.8); + sfxEnemyDied(); + triggerShake(120, 5); +} + +function onRelic(event: EventOf<'relic'>): void { + spawnPopup(event.x, event.y, '+relic', POPUP_RELIC); + spawnBurst(event.x, event.y, 'relic'); + sfxRelic(event.kind); + const offsetX = (canvas.width - GRID_WIDTH * TILE_SIZE) / 2; + const startX = offsetX + event.x * TILE_SIZE + TILE_SIZE / 2; + const startY = event.y * TILE_SIZE + TILE_SIZE / 2; + const counter = document.getElementById('hud-relics'); + const canvasRect = canvas.getBoundingClientRect(); + if (!counter || canvasRect.width <= 0 || canvasRect.height <= 0) return; + // The counter sits below the board in this layout: the point aims at the counter's + // live horizontal position but can only ever reach the board's own bottom edge. + const counterRect = counter.getBoundingClientRect(); + const scaleX = canvas.width / canvasRect.width; + const scaleY = canvas.height / canvasRect.height; + const endX = Math.min(canvas.width, Math.max(0, (counterRect.left + counterRect.width / 2 - canvasRect.left) * scaleX)); + const endY = Math.min(canvas.height - RELIC_TRAVEL_EDGE_MARGIN, (counterRect.top - canvasRect.top) * scaleY); + spawnRelicTravel(startX, startY, endX, endY, event.kind); +} + +function onFloorCleared(): void { + sfxFloorCleared(); + showHint('choice'); + if (session.run && session.run.floorNumber === MAX_FLOOR - 1) preloadBossArt(); +} + +function onDescended(event: EventOf<'descended'>): void { + resetMotion(); + sfxDescend(); + startAmbience(event.floor); + if (event.floor < MAX_FLOOR) { + showBanner(`Floor ${event.floor}`, FLOOR_NAMES[event.floor] ?? '', 'floor'); + sfxBanner(); + return; + } + const stage = document.querySelector('.stage'); + if (!stage) { + sfxBossIntro(); + return; + } + session.bossIntro = playBossIntro( + stage, + BOSS_NAME, + () => sfxBossIntro(), + () => { + session.bossIntro = null; + } + ); +} + +/** Books the finished run locally and, unless it is practice, sends its log to the contract. */ +function settleFinishedRun(): void { + const run = session.run; + if (run) settleLocalProgress(run, session.practice); + session.runId += 1; + if (run && !session.practice) void verifyOnChain(run); +} + +function onExtracted(): void { + stopAmbience(); + sfxExtracted(); + const run = session.run; + if (run) { + const heroId = HERO_ORDER[run.loadout.base] ?? 'lantern'; + preloadShareAssets(HEROES[heroId].veteranArt, run.loadout); + } + settleFinishedRun(); +} + +function onDead(): void { + stopAmbience(); + sfxDead(); + triggerShake(250, 10); + canvas.classList.add('board-sunk'); + settleFinishedRun(); +} + +/** Turns one simulation event into its feedback: effects, sound, hints and, at the end of a run, settlement. */ +export function handleEvent(event: GameEvent): void { + switch (event.type) { + case 'enemyHit': + onEnemyHit(event); + break; + case 'playerHit': + onPlayerHit(event); + break; + case 'enemyDied': + onEnemyDied(event); + break; + case 'relic': + onRelic(event); + break; + case 'floorCleared': + onFloorCleared(); + break; + case 'descended': + onDescended(event); + break; + case 'extracted': + onExtracted(); + break; + case 'dead': + onDead(); + break; + case 'combatStart': + showHint('combat'); + break; + } +} diff --git a/client/src/app/failures.ts b/client/src/app/failures.ts new file mode 100644 index 0000000..db87358 --- /dev/null +++ b/client/src/app/failures.ts @@ -0,0 +1,69 @@ +import { CONTRACT_ERROR, isContractError } from '../stellar/choice'; + +/** + * How a failed `finish_run` should be treated. + * - wait: network trouble or TooFast, heals by itself, retried automatically. + * - mint: the relics could not be delivered. The contract reverted and kept the run open, so it is safe to retry after the relic setup is redone. + * - notReady: the contract is not set up yet. The run is kept, the player retries by hand. + * - closed: the run is not open on chain (settled, expired or never started). + * - permanent: the contract refused this log for good. + */ +export type FailureKind = 'wait' | 'mint' | 'notReady' | 'closed' | 'permanent'; + +/** Raised when a retry found no open run and the record could not prove the run was settled. */ +export class SettlementUnconfirmed extends Error { + constructor() { + super('The run may already be recorded, but Stellar did not confirm it'); + this.name = 'SettlementUnconfirmed'; + } +} + +function isContractRefusal(err: unknown): boolean { + return err instanceof Error && err.message.includes('Error(Contract, #'); +} + +/** The one place that maps a contract refusal to how the client reacts. */ +export function failureKind(err: unknown): FailureKind { + if (!isContractRefusal(err)) return 'wait'; + if (isContractError(err, CONTRACT_ERROR.TooFast)) return 'wait'; + if (isContractError(err, CONTRACT_ERROR.MintFailed)) return 'mint'; + if (isContractError(err, CONTRACT_ERROR.NotInitialized)) return 'notReady'; + if (isContractError(err, CONTRACT_ERROR.RunNotStarted)) return 'closed'; + return 'permanent'; +} + +/** The contract's own refusals that waiting cannot fix and that leave no run to resume. */ +export function isPermanentFailure(err: unknown): boolean { + const kind = failureKind(err); + return kind === 'permanent' || kind === 'closed'; +} + +/** Failures after which the open run and its action log must be kept so the player can try again. */ +export function keepsRun(err: unknown): boolean { + return !isPermanentFailure(err); +} + +/** A player-facing sentence for why a run could not be settled. */ +export function describeFailure(err: unknown): string { + if (err instanceof SettlementUnconfirmed) { + return 'This run may already be recorded on Stellar, but we could not confirm it yet. Check again in a moment.'; + } + if (isContractError(err, CONTRACT_ERROR.MintFailed)) { + return 'Your relics could not be delivered yet, usually because your relic setup is not finished. Your run is safe. Try again.'; + } + if (isContractError(err, CONTRACT_ERROR.NotInitialized)) { + return 'The game is not ready on Stellar yet. Your run is kept. Try again in a moment.'; + } + if (isContractError(err, CONTRACT_ERROR.RunNotStarted)) { + return 'This run is no longer open on Stellar. It was already settled or it expired.'; + } + if (isContractError(err, CONTRACT_ERROR.NotHeroOwner)) return 'This delver changed hands during the run, so it cannot be settled.'; + if ( + isContractError(err, CONTRACT_ERROR.RunIncomplete) || + isContractError(err, CONTRACT_ERROR.TrailingActions) || + isContractError(err, CONTRACT_ERROR.InvalidAction) + ) { + return 'The contract could not reproduce this run, so it was not recorded.'; + } + return 'Stellar could not be reached to settle this run.'; +} diff --git a/client/src/app/floorNames.ts b/client/src/app/floorNames.ts new file mode 100644 index 0000000..4a9029d --- /dev/null +++ b/client/src/app/floorNames.ts @@ -0,0 +1,2 @@ +/** Banner names for each floor, keyed by floor number. */ +export const FLOOR_NAMES: Record = { 1: 'The Reliquary', 2: 'The Drowned Chapel', 3: 'The Obsidian Crypt' }; diff --git a/client/src/app/onchain.ts b/client/src/app/onchain.ts new file mode 100644 index 0000000..f7a4fe9 --- /dev/null +++ b/client/src/app/onchain.ts @@ -0,0 +1,312 @@ +import { delverOutcome } from '../game/delverOutcome'; +import { clearSavedRun } from '../game/savedRun'; +import type { RunState } from '../game/state'; +import type { LeaderboardRow, OnchainSnapshot } from '../render/chromeTypes'; +import { sfxHeroEarned } from '../render/audio'; +import { chain } from '../stellar/lazy'; +import { shortenAddress } from '../render/dom'; +import type { FinishedRun, TopEntry } from '../stellar/contract'; +import { fetchRelicBalances } from '../stellar/relics'; +import { HERO_EARNED_DELAY_MS, RESTORE_FIELD_ID, JUST_VERIFIED_MS, LEADERBOARD_ROWS, VERIFY_ATTEMPTS, VERIFY_RETRY_MS } from '../constants'; +import { chainQueue, sleep } from './chainQueue'; +import { describeFailure, failureKind, isPermanentFailure, keepsRun, SettlementUnconfirmed } from './failures'; +import { checkSettlement } from './settlement'; +import { session } from './session'; + +/** Reads the player's record and relic balances. The title never waits for it. */ +export async function refreshOnchain(): Promise { + try { + const api = await chain(); + const keypair = await api.getPlayerKeypair(); + session.playerAddress = keypair.publicKey(); + const [record, balances] = await Promise.all([api.fetchRecord(session.playerAddress), fetchRelicBalances(session.playerAddress)]); + session.onchainRecord = record; + session.relicBalances = balances; + session.chainRead = 'ready'; + } catch (err) { + session.chainRead = 'unavailable'; + console.warn('Deepmark: could not read the on-chain record yet', err); + } +} + +export function onchainSnapshot(): OnchainSnapshot | null { + const record = session.onchainRecord; + if (!record) return null; + return { + bestFloor: record.best_floor, + bestScore: record.best_score, + runs: record.total_runs, + extractions: record.extractions, + deaths: record.deaths, + forfeits: record.forfeits, + relicsBanked: record.relics_banked, + }; +} + +function toRows(entries: TopEntry[]): LeaderboardRow[] { + return entries.slice(0, LEADERBOARD_ROWS).map((entry) => ({ + address: entry.player, + name: entry.name, + emblem: entry.emblem, + score: entry.score, + floor: entry.floor, + hpLeft: entry.hp_left, + actions: entry.actions, + })); +} + +export async function loadBoard(): Promise { + const tab = session.boardTab; + session.leaderboardLoading = true; + session.leaderboardRows = null; + session.leaderboardFailed = false; + try { + const api = await chain(); + const entries = tab === 'veteran' ? await api.fetchTopVeteran() : await api.fetchTopFresh(); + if (tab === session.boardTab) session.leaderboardRows = toRows(entries); + } catch (err) { + console.warn('Deepmark: could not load the leaderboard', err); + if (tab === session.boardTab) session.leaderboardFailed = true; + } finally { + if (tab === session.boardTab) session.leaderboardLoading = false; + } +} + +export async function openLeaderboard(): Promise { + session.showLeaderboard = true; + if (session.leaderboardRows || session.leaderboardLoading) return; + await loadBoard(); +} + +export async function copyRecoveryCode(): Promise { + try { + const api = await chain(); + const code = api.getRecoveryCode(); + if (!code) throw new Error('No account yet'); + await navigator.clipboard.writeText(code); + session.recoveryNote = 'Recovery code copied. Keep it somewhere private, anyone who has it can use your account.'; + } catch (err) { + console.warn('Deepmark: could not copy the recovery code', err); + session.recoveryNote = 'The recovery code could not be copied from this browser.'; + } +} + +/** The validated code waiting for the player's confirmation. Module private so it is never rendered or logged. */ +let pendingRestoreCode: string | null = null; + +export function startRestore(): void { + pendingRestoreCode = null; + session.restoreError = ''; + session.restoreTarget = ''; + session.restoreStep = 'enter'; +} + +export function cancelRestore(): void { + pendingRestoreCode = null; + session.restoreError = ''; + session.restoreTarget = ''; + session.restoreStep = 'closed'; +} + +/** Validates the pasted code strictly. Nothing is replaced until the player confirms the next step. */ +export async function checkRestoreCode(): Promise { + const field = document.getElementById(RESTORE_FIELD_ID); + const pasted = field instanceof HTMLInputElement ? field.value : ''; + if (field instanceof HTMLInputElement) field.value = ''; + try { + const api = await chain(); + const code = pasted.trim(); + if (!api.isRecoveryCode(code)) { + session.restoreError = 'That recovery code is not valid. Nothing was changed.'; + return; + } + pendingRestoreCode = code; + session.restoreTarget = shortenAddress(api.recoveryCodeAddress(code)); + session.restoreError = ''; + session.restoreStep = 'confirm'; + } catch (err) { + console.warn('Deepmark: could not check the recovery code', err); + session.restoreError = 'That recovery code could not be checked. Nothing was changed.'; + } +} + +/** Swaps this browser's account for the one in the confirmed recovery code, then reloads. */ +export async function confirmRestore(): Promise { + const code = pendingRestoreCode; + if (!code) return; + try { + const api = await chain(); + api.restoreRecoveryCode(code); + pendingRestoreCode = null; + window.location.reload(); + } catch (err) { + console.warn('Deepmark: could not restore the account', err); + session.restoreError = 'That recovery code could not be used. Your current account was kept.'; + session.restoreStep = 'enter'; + pendingRestoreCode = null; + } +} + +export async function openVault(): Promise { + session.showVault = true; + if (session.vaultHeroes || session.vaultLoading) return; + session.vaultLoading = true; + try { + const api = await chain(); + const keypair = await api.getPlayerKeypair(); + session.vaultHeroes = await api.fetchHeroes(keypair.publicKey()); + } catch (err) { + console.warn('Deepmark: could not read the vault', err); + session.vaultHeroes = null; + } finally { + session.vaultLoading = false; + } +} + +type Api = Awaited>; + +/** Either the run just settled, or proof from the record that an earlier send already settled it. */ +type Settlement = { kind: 'finished'; run: FinishedRun } | { kind: 'recorded' }; + +async function readRunCount(api: Api): Promise { + try { + return (await api.fetchRecord((await api.getPlayerKeypair()).publicKey()))?.total_runs ?? 0; + } catch { + return null; + } +} + +/** Redoes the account's relic setup. A failure is logged only: the next send reports the real cause. */ +async function redoRelicSetup(api: Api): Promise { + session.chainStatus = 'Getting your relic setup ready...'; + session.verifyState = 'waiting'; + try { + await api.prepareRelicSetup(); + } catch (err) { + console.warn('Deepmark: relic setup did not finish', err); + } +} + +/** A retry found no open run. If an earlier send of this very run happened, the record says whether it landed. */ +async function settleWhenClosed(api: Api, err: unknown, previouslySent: boolean): Promise { + if (!previouslySent) throw err; + const address = (await api.getPlayerKeypair()).publicKey(); + const verdict = await checkSettlement(() => api.fetchRecord(address), session.finishBaselineRuns); + if (verdict === 'recorded') return { kind: 'recorded' }; + if (verdict === 'unknown') throw new SettlementUnconfirmed(); + throw err; +} + +async function submitWithRetries(api: Api, finished: RunState): Promise { + const previouslySent = session.finishAttemptRunId === session.runId; + if (!previouslySent) { + session.finishAttemptRunId = session.runId; + session.finishBaselineRuns = await readRunCount(api); + } + let setupRedone = false; + let sentBefore = previouslySent; + for (let attempt = 0; ; attempt += 1) { + try { + const run = await api.finishRun(await api.getPlayerKeypair(), finished.actions); + return { kind: 'finished', run }; + } catch (err) { + const kind = failureKind(err); + if (kind === 'closed') return settleWhenClosed(api, err, sentBefore); + sentBefore = true; + if (kind === 'mint' && !setupRedone) { + // The contract reverted and kept the run open, so one more send after the setup is safe. + setupRedone = true; + await redoRelicSetup(api); + continue; + } + if (kind !== 'wait' || attempt >= VERIFY_ATTEMPTS - 1) throw err; + session.chainStatus = 'Waiting for Stellar to settle this run...'; + session.verifyState = 'waiting'; + await sleep(VERIFY_RETRY_MS); + } + } +} + +/** Sends the log, waiting out the contract's minimum play time and brief network trouble. */ +function submitLog(finished: RunState): Promise { + // The whole retry loop holds the chain queue, sleeps included, so start_run + // cannot slip in between attempts and overwrite the run that is still open. + return chainQueue.enqueue(async () => { + const api = await chain(); + return submitWithRetries(api, finished); + }); +} + +/** Adds the player's leaderboard rank and refreshed record. Decoration only, so it never throws. */ +async function decorateResult(): Promise { + try { + await refreshOnchain(); + const api = await chain(); + if (session.runWasVeteran && session.chainHeroId !== null) { + session.chainClears = (await api.fetchHero(session.chainHeroId))?.clears ?? null; + } + const top = session.runWasVeteran ? await api.fetchTopVeteran() : await api.fetchTopFresh(); + const position = top.findIndex((entry) => entry.player === session.playerAddress); + session.chainRank = position >= 0 ? position + 1 : null; + session.leaderboardRows = null; + } catch (err) { + console.warn('Deepmark: could not refresh the leaderboard rank', err); + } +} + +/** Sends the action log. The contract replays it and decides the result. */ +export async function verifyOnChain(finished: RunState): Promise { + session.chainStatus = 'Verifying on Stellar...'; + session.verifyState = 'pending'; + session.canRetry = false; + let settled: Settlement; + try { + settled = await submitLog(finished); + } catch (err) { + console.warn('Deepmark: failed to verify this run on-chain', err); + session.chainStatus = describeFailure(err); + session.verifyState = 'failed'; + session.canRetry = !isPermanentFailure(err); + session.failedRun = finished; + // Only a refusal that waiting cannot fix means the run is gone for good. Otherwise the saved run and its log stay for another try. + if (!keepsRun(err)) clearSavedRun(); + return; + } + + if (settled.kind === 'recorded') { + // The send landed but its response was lost. The record proves it, so show the verified state. + session.failedRun = null; + clearSavedRun(); + session.chainTx = ''; + session.chainHeroId = null; + session.vaultHeroes = null; + session.chainStatus = 'Verified on Stellar (the confirmation arrived late, so the details come from your record)'; + session.verifyState = 'verified'; + session.justVerifiedUntil = performance.now() + JUST_VERIFIED_MS; + await decorateResult(); + return; + } + + const { hash, outcome } = settled.run; + session.failedRun = null; + clearSavedRun(); + session.chainTx = hash; + session.chainHeroId = outcome.hero_id ?? null; + if (session.chainHeroId !== null) session.vaultHeroes = null; + const relicsMatch = outcome.relics_common + outcome.relics_rare + outcome.relics_core === finished.relicsThisRun; + const agrees = + outcome.floor_reached === finished.floorNumber && + outcome.score === finished.scoreThisRun && + outcome.hp_left === finished.player.hp && + relicsMatch; + if (!agrees) console.error('Deepmark: the contract result differs from the local simulation', outcome); + session.chainStatus = agrees ? 'Verified on Stellar' : 'Verified on Stellar (this browser disagreed, the chain result stands)'; + session.verifyState = 'verified'; + session.justVerifiedUntil = performance.now() + JUST_VERIFIED_MS; + const earned = delverOutcome(session.chainHeroId, session.runWasVeteran, null).kind === 'earned'; + if (earned) window.setTimeout(sfxHeroEarned, HERO_EARNED_DELAY_MS); + + // The run is settled. Anything below only decorates the result screen, so a + // failure here must never turn a confirmed transaction into an error. + await decorateResult(); +} diff --git a/client/src/app/run.ts b/client/src/app/run.ts new file mode 100644 index 0000000..2d0f767 --- /dev/null +++ b/client/src/app/run.ts @@ -0,0 +1,274 @@ +import { loadDraft, loadoutOf, type Draft } from '../game/draft'; +import { HEROES, HERO_ORDER } from '../game/heroes'; +import { CHARMS, WEAPONS } from '../game/items'; +import { rankForClears, type Loadout, type Look } from '../game/loadout'; +import { recordRun } from '../game/progress'; +import { clearSavedRun, loadSavedRun, planResume, saveRun, type ResumePlan } from '../game/savedRun'; +import { newRun, type RunState } from '../game/state'; +import { showBanner } from '../render/banner'; +import { cloakFilter } from '../render/emblems'; +import { resetRelicTravel } from '../render/effects'; +import { showHint } from '../render/hints'; +import { resetMotion } from '../render/motion'; +import { startAmbience, sfxDescend } from '../render/audio'; +import { preloadShareAssets } from '../render/shareCard'; +import { CONTRACT_ERROR, freshChoice, isContractError, veteranChoice } from '../stellar/choice'; +import type { Hero, HeroChoice, RunTicket } from '../stellar/contract'; +import { chain } from '../stellar/lazy'; +import { isRetryableTxFailure } from '../stellar/txOutcome'; +import { retryTransient } from '../stellar/transient'; +import { + MIN_SCENE_MS, + OFFLINE_EXTRA_HOLD_MS, + READY_HOLD_MS, + START_CONTENTION_BACKOFF_MS, + START_CONTENTION_JITTER_MS, + START_CONTENTION_RETRIES, + START_RETRIES, + START_RETRY_MS, + START_TIMEOUT_MS, +} from '../constants'; +import { canvas } from './canvas'; +import { raceSkip, SKIPPED, SkipGate } from './skipGate'; +import { chainQueue, sleep, withTimeout } from './chainQueue'; +import { FLOOR_NAMES } from './floorNames'; +import { session } from './session'; +import { verifyOnChain } from './onchain'; + +export interface DelverPlan { + choice: HeroChoice; + loadout: Loadout; + look: Look; + veteran: boolean; +} + +export function planFresh(chosen: Draft): DelverPlan { + return { + choice: freshChoice({ look: chosen.look, base: chosen.base, weapon: chosen.weapon, charm: chosen.charm }), + loadout: loadoutOf(chosen), + look: chosen.look, + veteran: false, + }; +} + +export function planVeteran(hero: Hero): DelverPlan { + return { + choice: veteranChoice(hero.id), + loadout: { base: hero.base, weapon: hero.weapon, charm: hero.charm, rank: rankForClears(hero.clears) }, + look: hero.look, + veteran: true, + }; +} + +function resetChainReadout(): void { + session.chainStatus = ''; + session.chainTx = ''; + session.chainRank = null; + session.chainHeroId = null; + session.chainClears = null; +} + +/** Calls start_run, riding out the contract's cooldown between delves. */ +async function openRunOnChain(choice: HeroChoice, gate: SkipGate): Promise { + const api = await chain(); + const keypair = await api.getPlayerKeypair(); + if (!gate.closed) session.startStage = 'opening'; + let cooldownRetries = 0; + let contentionRetries = 0; + for (;;) { + try { + // A passing HTTP or network error on the send is retried before the descent gives up. + return await retryTransient(() => api.startRun(keypair, choice), { cancelled: () => gate.closed }); + } catch (err) { + if (gate.closed) throw err; + if (isContractError(err, CONTRACT_ERROR.StartCooldown) && cooldownRetries < START_RETRIES) { + cooldownRetries += 1; + await sleep(START_RETRY_MS); + } else if (isRetryableTxFailure(err) && contentionRetries < START_CONTENTION_RETRIES) { + // Several players starting in the same ledger can clash on the shared + // player index. The failed transaction had no effect, so simulate again. + contentionRetries += 1; + await sleep(START_CONTENTION_BACKOFF_MS * contentionRetries + Math.random() * START_CONTENTION_JITTER_MS); + } else { + throw err; + } + } + } +} + +function enterFirstFloor(): void { + resetMotion(); + startAmbience(1); + showBanner('Floor 1', FLOOR_NAMES[1] ?? ''); +} + +function descentOptionsFor(plan: DelverPlan, onSkip: () => void): NonNullable { + const heroId = HERO_ORDER[plan.loadout.base] ?? 'lantern'; + return { + art: plan.loadout.rank > 0 ? HEROES[heroId].veteranArt : HEROES[heroId].art, + filter: cloakFilter(plan.look.cloak, plan.loadout.base), + name: plan.look.name, + emblem: plan.look.emblem, + onSkip, + kit: [ + HEROES[heroId].name, + WEAPONS[plan.loadout.weapon]?.name ?? '', + CHARMS[plan.loadout.charm]?.name ?? '', + ...(plan.loadout.rank > 0 ? [`Veteran rank ${plan.loadout.rank}`] : []), + ].join(' · '), + }; +} + +/** Plays a labelled practice run in place of a verified one. `immediate` skips the notice hold. */ +async function startPractice(plan: DelverPlan, immediate: boolean, sceneRemaining: () => number): Promise { + if (!immediate) await sleep(sceneRemaining()); + session.startStage = 'offline'; + if (!immediate) await sleep(READY_HOLD_MS + OFFLINE_EXTRA_HOLD_MS); + enterFirstFloor(); + session.run = newRun(plan.loadout, session.challengeSeed ?? undefined, plan.look); + session.challengeSeed = null; + session.practice = true; +} + +/** Opens a run on chain so the contract issues the seed. Falls back to a practice run. */ +export async function beginRun(plan: DelverPlan): Promise { + if (session.starting) return; + session.starting = true; + session.displayedRelics = 0; + resetRelicTravel(); + canvas.classList.remove('board-sunk'); + session.startStage = 'account'; + const openedAt = performance.now(); + const gate = new SkipGate(); + session.descent = descentOptionsFor(plan, () => gate.skip()); + resetChainReadout(); + session.runWasVeteran = plan.veteran; + const sceneRemaining = (): number => Math.max(0, MIN_SCENE_MS - (performance.now() - openedAt)); + try { + const ticket = await raceSkip( + withTimeout( + chainQueue.enqueue(() => openRunOnChain(plan.choice, gate)), + START_TIMEOUT_MS + ), + gate + ); + if (ticket === SKIPPED) { + await startPractice(plan, true, sceneRemaining); + } else { + await sleep(sceneRemaining()); + session.startStage = 'ready'; + sfxDescend(); + await sleep(READY_HOLD_MS); + enterFirstFloor(); + // The ticket is authoritative: the contract fixed the delver and the seed. + const loadout: Loadout = { base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank }; + session.run = newRun(loadout, ticket.seed, ticket.look); + session.practice = false; + saveRun(session.run); + } + } catch (err) { + console.warn('Deepmark: could not open a verified run, starting a practice run', err); + await startPractice(plan, false, sceneRemaining); + } finally { + gate.close(); + session.starting = false; + session.descent = null; + } + showHint('move'); +} + +/** A run that grows past what the contract accepts can no longer be verified. */ +export function endExhaustedRun(state: RunState): void { + if (state.mode === 'result') return; + state.mode = 'result'; + state.resultKind = 'dead'; + state.relicsThisRun = 0; + state.scoreThisRun = 0; + session.practice = true; + clearSavedRun(); + session.chainStatus = 'This run grew too long to be verified.'; +} + +/** Books a finished run into this browser's own progress, unless it was a practice run. The simulation stays free of storage so a log can be replayed safely. */ +export function settleLocalProgress(finished: RunState, practice: boolean): void { + const extracted = finished.resultKind === 'extracted'; + const result = recordRun({ + floorReached: finished.floorNumber, + relicsKept: finished.relicsThisRun, + score: finished.scoreThisRun, + extracted, + heroId: finished.heroId, + practice, + }); + session.progress = result.progress; + finished.newBest = result.isNewBest && extracted; +} + +/** Puts a run rebuilt from its saved log back on screen, at the point it was left. */ +function presentResumedRun(plan: Exclude, ticket: RunTicket): void { + const resumed = plan.run; + resetMotion(); + resetRelicTravel(); + canvas.classList.remove('board-sunk'); + resetChainReadout(); + session.canRetry = false; + session.practice = false; + session.runWasVeteran = typeof ticket.veteran === 'number'; + session.displayedRelics = resumed.relicsThisRun; + session.run = resumed; + if (plan.kind === 'continue') { + startAmbience(resumed.floorNumber); + showBanner('Run restored', FLOOR_NAMES[resumed.floorNumber] ?? ''); + return; + } + // The run had already ended when the page went away: show its result and send the log. + session.runId += 1; + if (resumed.resultKind === 'dead') canvas.classList.add('board-sunk'); + else preloadShareAssets(HEROES[resumed.heroId].veteranArt, resumed.loadout); + void verifyOnChain(resumed); +} + +/** + * If this browser holds a saved run, asks the contract whether that very run is + * still open and, if so, restores it. A saved run the contract no longer knows + * is dropped. If Stellar cannot be read the save is kept for the next visit. + */ +export async function resumeOpenRun(): Promise { + if (session.resumeChecked || session.chainRead !== 'ready') return; + session.resumeChecked = true; + const saved = loadSavedRun(); + if (!saved) return; + try { + const api = await chain(); + const ticket = await api.fetchOpenRun(session.playerAddress); + if (session.run || session.starting) return; + const plan = planResume(saved, ticket); + if (plan.kind === 'discard' || !ticket) { + clearSavedRun(); + return; + } + presentResumedRun(plan, ticket); + } catch (err) { + console.warn('Deepmark: could not check for an open run', err); + } +} + +/** Leaves the result screen for the title, clearing everything that belonged to the finished run. */ +export function leaveResult(): void { + resetMotion(); + session.run = null; + session.chainStatus = ''; + session.chainTx = ''; + session.chainRank = null; + session.canRetry = false; +} + +/** Opens the delver creator, keeping a delver that arrived with a challenge link. */ +export function openCreator(): void { + if (session.pendingChallengeDraft) { + session.pendingChallengeDraft = false; + } else { + session.draft = loadDraft(); + } + session.showCreator = true; +} diff --git a/client/src/app/session.ts b/client/src/app/session.ts new file mode 100644 index 0000000..9dd57a1 --- /dev/null +++ b/client/src/app/session.ts @@ -0,0 +1,135 @@ +import { loadDraft, takeChallengeFromUrl, type Draft } from '../game/draft'; +import { loadProgress, type Progress } from '../game/progress'; +import type { RunState } from '../game/state'; +import type { BoardTab, ChainRead, LeaderboardRow } from '../render/chromeTypes'; +import type { BossIntroHandle } from '../render/bossIntro'; +import type { DescentOptions, DescentStage } from '../render/descent'; +import type { VerifyState } from '../render/result'; +import type { Hero, PlayerRecord } from '../stellar/contract'; +import type { RelicBalances } from '../stellar/relics'; + +export type RestoreStep = 'closed' | 'enter' | 'confirm'; + +/** Everything the app layer shares between frames. One object so each module reads and writes the same state. */ +export interface Session { + run: RunState | null; + progress: Progress; + /** A finished run whose log could not be sent, kept so the player can retry. */ + failedRun: RunState | null; + practice: boolean; + starting: boolean; + startStage: DescentStage; + descent: DescentOptions | null; + runId: number; + runWasVeteran: boolean; + /** What the HUD counter actually shows: bumps when a relic's travelling point lands, not when it is picked up. */ + displayedRelics: number; + bossIntro: BossIntroHandle | null; + lastHeartbeat: number; + resultShownRunId: number; + resultShownAt: number; + + draft: Draft; + challengeSeed: number | null; + pendingChallengeDraft: boolean; + showCreator: boolean; + + showVault: boolean; + vaultHeroes: Hero[] | null; + vaultLoading: boolean; + + showLeaderboard: boolean; + boardTab: BoardTab; + leaderboardLoading: boolean; + leaderboardRows: LeaderboardRow[] | null; + leaderboardFailed: boolean; + + showStats: boolean; + recoveryNote: string; + /** Where the restore-from-a-code flow is. The pasted code itself is never kept here. */ + restoreStep: RestoreStep; + restoreError: string; + /** The shortened public address of the account a pending restore would switch to. */ + restoreTarget: string; + + chainRead: ChainRead; + chainStatus: string; + chainTx: string; + chainRank: number | null; + chainHeroId: number | null; + chainClears: number | null; + canRetry: boolean; + verifyState: VerifyState; + /** Set only when a run verifies this session, so the leaderboard's own-row glow never shows on a cold load. */ + justVerifiedUntil: number; + onchainRecord: PlayerRecord | null; + relicBalances: RelicBalances | null; + playerAddress: string; + resumeChecked: boolean; + /** The run whose log was last sent, so a later "no open run" answer can be checked against the record. */ + finishAttemptRunId: number; + /** The player's run count read just before the first send, or null if it could not be read. */ + finishBaselineRuns: number | null; +} + +function createSession(): Session { + let draft = loadDraft(); + let challengeSeed: number | null = null; + let pendingChallengeDraft = false; + const challenge = takeChallengeFromUrl(); + if (challenge) { + draft = { ...draft, base: challenge.base, weapon: challenge.weapon, charm: challenge.charm }; + challengeSeed = challenge.seed; + pendingChallengeDraft = true; + } + return { + run: null, + progress: loadProgress(), + failedRun: null, + practice: false, + starting: false, + startStage: 'account', + descent: null, + runId: 0, + runWasVeteran: false, + displayedRelics: 0, + bossIntro: null, + lastHeartbeat: 0, + resultShownRunId: -1, + resultShownAt: 0, + draft, + challengeSeed, + pendingChallengeDraft, + showCreator: false, + showVault: false, + vaultHeroes: null, + vaultLoading: false, + showLeaderboard: false, + boardTab: 'fresh', + leaderboardLoading: false, + leaderboardRows: null, + leaderboardFailed: false, + showStats: false, + recoveryNote: '', + restoreStep: 'closed', + restoreError: '', + restoreTarget: '', + chainRead: 'loading', + chainStatus: '', + chainTx: '', + chainRank: null, + chainHeroId: null, + chainClears: null, + canRetry: false, + verifyState: 'pending', + justVerifiedUntil: 0, + onchainRecord: null, + relicBalances: null, + playerAddress: '', + resumeChecked: false, + finishAttemptRunId: -1, + finishBaselineRuns: null, + }; +} + +export const session: Session = createSession(); diff --git a/client/src/app/settlement.ts b/client/src/app/settlement.ts new file mode 100644 index 0000000..a9e0b07 --- /dev/null +++ b/client/src/app/settlement.ts @@ -0,0 +1,22 @@ +import type { PlayerRecord } from '../stellar/contract'; + +/** What the contract's record says about a run whose finish response was lost. */ +export type SettlementCheck = 'recorded' | 'notRecorded' | 'unknown'; + +/** + * `finish_run` bumps `total_runs` exactly once when it lands. A retry that finds + * no open run is only proof of settlement if the count moved past what it was + * before the first attempt. Without a baseline or a readable record we cannot say. + */ +export async function checkSettlement( + readRecord: () => Promise, + baselineRuns: number | null +): Promise { + if (baselineRuns === null) return 'unknown'; + try { + const record = await readRecord(); + return (record?.total_runs ?? 0) > baselineRuns ? 'recorded' : 'notRecorded'; + } catch { + return 'unknown'; + } +} diff --git a/client/src/app/share.ts b/client/src/app/share.ts new file mode 100644 index 0000000..0110359 --- /dev/null +++ b/client/src/app/share.ts @@ -0,0 +1,63 @@ +import type { RunState } from '../game/state'; +import { MAX_FLOOR } from '../game/floor'; +import { HEROES, HERO_ORDER } from '../game/heroes'; +import { cloakFilter } from '../render/emblems'; +import { challengeUrl, renderShareCard } from '../render/shareCard'; +import { OBJECT_URL_REVOKE_MS, SHARE_FLASH_MS } from '../constants'; +import { session } from './session'; + +type ShareButton = 'shareSave' | 'shareCopy' | 'shareChallenge'; + +function flashShareButton(action: ShareButton, label: string): void { + const btn = document.querySelector(`[data-action="${action}"]`); + if (!btn) return; + const original = btn.textContent ?? ''; + btn.textContent = label; + window.setTimeout(() => { + btn.textContent = original; + }, SHARE_FLASH_MS); +} + +export async function shareResult(target: RunState, mode: 'save' | 'copy'): Promise { + const heroId = HERO_ORDER[target.loadout.base] ?? 'lantern'; + const blob = await renderShareCard({ + name: target.look.name, + portraitArt: HEROES[heroId].veteranArt, + portraitFilter: cloakFilter(target.look.cloak, target.loadout.base), + weapon: target.loadout.weapon, + charm: target.loadout.charm, + floorReached: target.floorNumber, + maxFloor: MAX_FLOOR, + score: target.scoreThisRun, + relics: target.relicsThisRun, + rank: session.chainRank, + txHash: session.chainTx, + }); + if (!blob) return; + if (mode === 'copy' && typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + flashShareButton('shareCopy', 'Copied'); + return; + } catch { + // Clipboard image writes are not supported everywhere (notably iOS Safari): fall back to a download. + } + } + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `deepmark-${target.look.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.png`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_MS); + flashShareButton(mode === 'copy' ? 'shareCopy' : 'shareSave', 'Saved'); +} + +export async function copyChallengeLink(target: RunState): Promise { + const url = challengeUrl(target.seed, target.loadout); + try { + await navigator.clipboard.writeText(url); + flashShareButton('shareChallenge', 'Copied'); + } catch { + // No clipboard permission: the button simply does not confirm. + } +} diff --git a/client/src/app/skipGate.ts b/client/src/app/skipGate.ts new file mode 100644 index 0000000..184b32a --- /dev/null +++ b/client/src/app/skipGate.ts @@ -0,0 +1,46 @@ +/** Resolves to this when the player chose to skip the wait. */ +export const SKIPPED: unique symbol = Symbol('skipped'); + +/** + * One descent's "play a practice run now" switch. The chain job keeps running + * in the queue, but once the gate is closed its late results are ignored and + * it stops retrying, so they can never reach the practice run. + */ +export class SkipGate { + private requested = false; + private finished = false; + private resolveSkip: (value: typeof SKIPPED) => void = () => undefined; + readonly promise: Promise; + + constructor() { + this.promise = new Promise((resolve) => { + this.resolveSkip = resolve; + }); + } + + /** The player asked for a practice run now. */ + skip(): void { + if (this.finished) return; + this.requested = true; + this.resolveSkip(SKIPPED); + } + + /** The descent no longer depends on the chain job (opened, timed out or skipped). */ + close(): void { + this.finished = true; + } + + get skipped(): boolean { + return this.requested; + } + + /** True once the chain job's answer will be ignored, so it should stop touching shared state. */ + get closed(): boolean { + return this.finished || this.requested; + } +} + +/** The job's result, or SKIPPED if the player skipped first. A later failure of the job is swallowed. */ +export function raceSkip(job: Promise, gate: SkipGate): Promise { + return Promise.race([job, gate.promise]); +} diff --git a/client/src/constants.ts b/client/src/constants.ts new file mode 100644 index 0000000..36ec011 --- /dev/null +++ b/client/src/constants.ts @@ -0,0 +1,57 @@ +/** Named timings and thresholds shared by the app layer and the chrome. Each value is in the unit its name says. */ + +/** The bar and the heartbeat both treat this share of max health as low. */ +export const LOW_HEALTH_RATIO = 0.34; +export const HEARTBEAT_MS = 1100; + +/** Waiting longer than this for the chain to open a run falls back to a practice run. */ +export const START_TIMEOUT_MS = 50_000; +export const START_RETRIES = 3; +export const START_RETRY_MS = 6_000; +/** Starts that failed on chain because other players started in the same ledger are simulated again. */ +export const START_CONTENTION_RETRIES = 4; +export const START_CONTENTION_BACKOFF_MS = 1_200; +/** Random spread added to the backoff so clashing players do not retry in lockstep. */ +export const START_CONTENTION_JITTER_MS = 1_500; +/** A transient network failure while sending a start (or account setup) is retried this many times, with a growing, jittered wait. */ +export const TRANSIENT_RETRIES = 3; +export const TRANSIENT_BACKOFF_MS = 900; +export const TRANSIENT_JITTER_MS = 700; +/** The descent scene says Stellar is slow after this long, and offers a practice run right away after the next. */ +export const SLOW_NOTE_AFTER_MS = 6_000; +export const SKIP_OFFER_AFTER_MS = 9_000; +/** The stairs-open beat before the first floor appears, so the scene resolves instead of cutting. */ +export const READY_HOLD_MS = 1400; +/** The practice fallback holds its notice a little longer so it can be read. */ +export const OFFLINE_EXTRA_HOLD_MS = 600; +/** The scene always plays at least this long, so a fast network never shows a flash of it. */ +export const MIN_SCENE_MS = 3200; + +/** How many times a finished run's log is sent, waiting out the contract's minimum play time. */ +export const VERIFY_ATTEMPTS = 6; +export const VERIFY_RETRY_MS = 6_000; + +/** The leaderboard's own-row glow lasts this long after a run verifies. */ +export const JUST_VERIFIED_MS = 4000; +/** The earned delver sting lands just after the seal strike. */ +export const HERO_EARNED_DELAY_MS = 900; + +export const LEADERBOARD_ROWS = 10; + +/** Share buttons show their confirmation label this long. */ +export const SHARE_FLASH_MS = 1600; +export const OBJECT_URL_REVOKE_MS = 2000; + +/** Hovering Descend: the heartbeat starts slow and quickens by this factor down to a floor. */ +export const DESCEND_BEAT_START_MS = 650; +export const DESCEND_BEAT_MIN_MS = 220; +export const DESCEND_BEAT_DECAY = 0.82; + +/** The relic counter bumps this long after a core relic lands, in step with the chime. */ +export const CORE_BUMP_DELAY_MS = 140; + +/** The board backdrop painted when no world is drawn. */ +export const BACKDROP_COLOR = '#07080e'; + +/** The id of the in-page field the player pastes a recovery code into. */ +export const RESTORE_FIELD_ID = 'restore-code'; diff --git a/client/src/game-app.ts b/client/src/game-app.ts index e52086a..c20194f 100644 --- a/client/src/game-app.ts +++ b/client/src/game-app.ts @@ -1,899 +1,134 @@ -import { ActionCode, applyAction, drainEvents, newRun, type GameEvent, type RunState } from './game/state'; -import { loadProgress, recordRun, type Progress } from './game/progress'; -import { clearSavedRun, loadSavedRun, planResume, saveRun, type ResumePlan } from './game/savedRun'; -import { onAction, type Action } from './input/keyboard'; -import { drawWorld, face } from './render/canvas'; -import { playAttack, resetMotion } from './render/motion'; -import { - syncChrome, - type BoardTab, - type ChainRead, - type LeaderboardRow, - type OnchainSnapshot, -} from './render/chrome'; -import { drainArrivedRelics, flashActor, resetRelicTravel, spawnBurst, spawnPopup, spawnRelicTravel, triggerShake } from './render/effects'; -import { showBanner } from './render/banner'; -import { playBossIntro, preloadBossArt, type BossIntroHandle } from './render/bossIntro'; -import { hitStop, punch } from './render/clock'; -import type { VerifyState } from './render/result'; -import { - sfxDead, - sfxDescend, - sfxEnemyDied, - sfxExtracted, - sfxFloorCleared, - sfxHitEnemy, - sfxHitPlayer, - sfxBanner, - sfxBossIntro, - sfxHeartbeat, - sfxHeroEarned, - sfxSeal, - sfxRelic, - sfxRelicArrive, - sfxStep, - sfxUi, - isMuted, - startAmbience, - stopAmbience, - toggleMuted, -} from './render/audio'; -import { loadDraft, loadoutOf, takeChallengeFromUrl, type Draft } from './game/draft'; -import { noteHintInput, resetHints, showHint } from './render/hints'; -import { challengeUrl, preloadShareAssets, renderShareCard } from './render/shareCard'; -import { GRID_WIDTH, MAX_FLOOR, TILE_SIZE } from './game/floor'; -import { HEROES, HERO_ORDER } from './game/heroes'; -import { CHARMS, WEAPONS } from './game/items'; -import { cloakFilter } from './render/emblems'; -import type { DescentOptions, DescentStage } from './render/descent'; -import { rankForClears, type Loadout, type Look } from './game/loadout'; -import { chain, warmChain } from './stellar/lazy'; -import { CONTRACT_ERROR, freshChoice, isContractError, veteranChoice } from './stellar/choice'; -import type { FinishedRun, Hero, HeroChoice, PlayerRecord, RunTicket, TopEntry } from './stellar/contract'; -import { fetchRelicBalances, type RelicBalances } from './stellar/relics'; - -const canvas = document.getElementById('game') as HTMLCanvasElement; -const context = canvas.getContext('2d'); -if (!context) throw new Error('Canvas 2D context unavailable'); -const ctx: CanvasRenderingContext2D = context; - -let run: RunState | null = null; -let progress: Progress = loadProgress(); -let chainStatus = ''; -let chainTx = ''; -let chainRank: number | null = null; -let onchainRecord: PlayerRecord | null = null; -let relicBalances: RelicBalances | null = null; -let playerAddress = ''; - -let draft: Draft = loadDraft(); -let challengeSeed: number | null = null; -let pendingChallengeDraft = false; -{ - const challenge = takeChallengeFromUrl(); - if (challenge) { - draft = { ...draft, base: challenge.base, weapon: challenge.weapon, charm: challenge.charm }; - challengeSeed = challenge.seed; - pendingChallengeDraft = true; - } -} -let showCreator = false; -let showVault = false; -let vaultHeroes: Hero[] | null = null; -let vaultLoading = false; -let boardTab: BoardTab = 'fresh'; -let chainHeroId: number | null = null; -let canRetry = false; -let verifyState: VerifyState = 'pending'; -let runId = 0; -/** What the HUD counter actually shows: bumps when a relic's travelling point lands, not when it is picked up. */ -let displayedRelics = 0; -/** Set only when a run verifies this session, so the leaderboard's own-row glow never shows on a cold load. */ -let justVerifiedUntil = 0; - -const FLOOR_NAMES: Record = { 1: 'The Reliquary', 2: 'The Drowned Chapel', 3: 'The Obsidian Crypt' }; -const HEARTBEAT_MS = 1100; -const LOW_HEALTH = 0.34; -let lastHeartbeat = 0; -let failedRun: RunState | null = null; -let runWasVeteran = false; -let showLeaderboard = false; -let showStats = false; -let starting = false; -let startStage: DescentStage = 'account'; -let descent: DescentOptions | null = null; -let practice = false; -let leaderboardLoading = false; -let leaderboardRows: LeaderboardRow[] | null = null; -let chainRead: ChainRead = 'loading'; -let bossIntro: BossIntroHandle | null = null; - -/** Waiting longer than this for the chain to open a run falls back to a practice run. */ -const START_TIMEOUT_MS = 50_000; -const START_RETRIES = 3; -/** The stairs-open beat before the first floor appears, so the scene resolves instead of cutting. */ -const READY_HOLD_MS = 1400; -/** The scene always plays at least this long, so a fast network never shows a flash of it. */ -const MIN_SCENE_MS = 3200; -const START_RETRY_MS = 6_000; -const LEADERBOARD_ROWS = 10; - -// One chain transaction at a time. Two transactions from the same account -// built before the first lands would reuse a sequence number and one of them -// would fail, so starting a run waits for the previous run's verification. -let chainQueue: Promise = Promise.resolve(); -function enqueue(job: () => Promise): Promise { - const next = chainQueue.then(job, job); - chainQueue = next.catch(() => undefined); - return next; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function withTimeout(promise: Promise, ms: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Timed out waiting for Stellar')), ms); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error: unknown) => { - clearTimeout(timer); - reject(error); - } - ); - }); -} - -async function refreshOnchain(): Promise { - try { - const api = await chain(); - const keypair = await api.getPlayerKeypair(); - playerAddress = keypair.publicKey(); - const [record, balances] = await Promise.all([api.fetchRecord(playerAddress), fetchRelicBalances(playerAddress)]); - onchainRecord = record; - relicBalances = balances; - chainRead = 'ready'; - } catch (err) { - chainRead = 'unavailable'; - console.warn('Deepmark: could not read the on-chain record yet', err); - } -} +import { drainArrivedRelics } from './render/effects'; +import { syncChrome } from './render/chrome'; +import { drawWorld } from './render/canvas'; +import { sfxHeartbeat, sfxRelicArrive, sfxSeal } from './render/audio'; +import { warmChain } from './stellar/lazy'; +import { BACKDROP_COLOR, CORE_BUMP_DELAY_MS, HEARTBEAT_MS, LOW_HEALTH_RATIO } from './constants'; +import { installInput } from './app/actions'; +import { ctx } from './app/canvas'; +import { isDescendHovering, onDescendHover } from './app/descendHeartbeat'; +import { onchainSnapshot, refreshOnchain } from './app/onchain'; +import { beginRun, planFresh, planVeteran, resumeOpenRun } from './app/run'; +import { session } from './app/session'; + +/** Composition root: wires input, the chain warm-up and the frame loop. The rules live in `app/`. */ // Load the Stellar chunk when idle, then read the player's record. The title never waits for either. warmChain(() => void refreshOnchain().then(resumeOpenRun)); +installInput(); -function onchainSnapshot(): OnchainSnapshot | null { - if (!onchainRecord) return null; - return { - bestFloor: onchainRecord.best_floor, - bestScore: onchainRecord.best_score, - runs: onchainRecord.total_runs, - extractions: onchainRecord.extractions, - deaths: onchainRecord.deaths, - forfeits: onchainRecord.forfeits, - relicsBanked: onchainRecord.relics_banked, - }; -} - -function toRows(entries: TopEntry[]): LeaderboardRow[] { - return entries.slice(0, LEADERBOARD_ROWS).map((entry) => ({ - address: entry.player, - name: entry.name, - emblem: entry.emblem, - score: entry.score, - floor: entry.floor, - hpLeft: entry.hp_left, - actions: entry.actions, - })); -} - -async function loadBoard(): Promise { - const tab = boardTab; - leaderboardLoading = true; - leaderboardRows = null; - try { - const api = await chain(); - const entries = tab === 'veteran' ? await api.fetchTopVeteran() : await api.fetchTopFresh(); - if (tab === boardTab) leaderboardRows = toRows(entries); - } catch (err) { - console.warn('Deepmark: could not load the leaderboard', err); - if (tab === boardTab) leaderboardRows = []; - } finally { - if (tab === boardTab) leaderboardLoading = false; - } -} - -async function openLeaderboard(): Promise { - showLeaderboard = true; - if (leaderboardRows || leaderboardLoading) return; - await loadBoard(); -} - -async function openVault(): Promise { - showVault = true; - if (vaultHeroes || vaultLoading) return; - vaultLoading = true; - try { - const api = await chain(); - const keypair = await api.getPlayerKeypair(); - vaultHeroes = await api.fetchHeroes(keypair.publicKey()); - } catch (err) { - console.warn('Deepmark: could not read the vault', err); - vaultHeroes = null; - } finally { - vaultLoading = false; - } -} - -interface DelverPlan { - choice: HeroChoice; - loadout: Loadout; - look: Look; - veteran: boolean; -} - -function planFresh(chosen: Draft): DelverPlan { - return { - choice: freshChoice({ look: chosen.look, base: chosen.base, weapon: chosen.weapon, charm: chosen.charm }), - loadout: loadoutOf(chosen), - look: chosen.look, - veteran: false, - }; -} - -function planVeteran(hero: Hero): DelverPlan { - return { - choice: veteranChoice(hero.id), - loadout: { base: hero.base, weapon: hero.weapon, charm: hero.charm, rank: rankForClears(hero.clears) }, - look: hero.look, - veteran: true, - }; -} - -/** Opens a run on chain so the contract issues the seed. Falls back to a practice run. */ -async function beginRun(plan: DelverPlan): Promise { - if (starting) return; - starting = true; - displayedRelics = 0; - resetRelicTravel(); - canvas.classList.remove('board-sunk'); - startStage = 'account'; - const openedAt = performance.now(); - const heroId = HERO_ORDER[plan.loadout.base] ?? 'lantern'; - descent = { - art: plan.loadout.rank > 0 ? HEROES[heroId].veteranArt : HEROES[heroId].art, - filter: cloakFilter(plan.look.cloak, plan.loadout.base), - name: plan.look.name, - emblem: plan.look.emblem, - kit: [ - HEROES[heroId].name, - WEAPONS[plan.loadout.weapon]?.name ?? '', - CHARMS[plan.loadout.charm]?.name ?? '', - ...(plan.loadout.rank > 0 ? [`Veteran rank ${plan.loadout.rank}`] : []), - ].join(' · '), - }; - chainStatus = ''; - chainTx = ''; - chainRank = null; - chainHeroId = null; - runWasVeteran = plan.veteran; - try { - const ticket = await withTimeout( - enqueue(async () => { - const api = await chain(); - const keypair = await api.getPlayerKeypair(); - startStage = 'opening'; - for (let attempt = 0; ; attempt += 1) { - try { - return await api.startRun(keypair, plan.choice); - } catch (err) { - const cooling = isContractError(err, CONTRACT_ERROR.StartCooldown); - if (!cooling || attempt >= START_RETRIES) throw err; - await sleep(START_RETRY_MS); - } - } - }), - START_TIMEOUT_MS - ); - await sleep(Math.max(0, MIN_SCENE_MS - (performance.now() - openedAt))); - startStage = 'ready'; - sfxDescend(); - await sleep(READY_HOLD_MS); - resetMotion(); - startAmbience(1); - showBanner('Floor 1', FLOOR_NAMES[1] ?? ''); - // The ticket is authoritative: the contract fixed the delver and the seed. - const loadout: Loadout = { base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank }; - run = newRun(loadout, ticket.seed, ticket.look); - practice = false; - saveRun(run); - } catch (err) { - console.warn('Deepmark: could not open a verified run, starting a practice run', err); - await sleep(Math.max(0, MIN_SCENE_MS - (performance.now() - openedAt))); - startStage = 'offline'; - await sleep(READY_HOLD_MS + 600); - resetMotion(); - startAmbience(1); - showBanner('Floor 1', FLOOR_NAMES[1] ?? ''); - run = newRun(plan.loadout, challengeSeed ?? undefined, plan.look); - challengeSeed = null; - practice = true; - } finally { - starting = false; - descent = null; - } - showHint('move'); -} - -/** Sends the action log. The contract replays it and decides the result. */ -const VERIFY_ATTEMPTS = 6; -const VERIFY_RETRY_MS = 6_000; - -/** The contract's own refusals do not heal by waiting. Only TooFast and network trouble do. */ -function isPermanentFailure(err: unknown): boolean { - if (!(err instanceof Error) || !err.message.includes('Error(Contract, #')) return false; - return !isContractError(err, CONTRACT_ERROR.TooFast); -} - -function describeFailure(err: unknown): string { - if (isContractError(err, CONTRACT_ERROR.RunNotStarted)) { - return 'This run is no longer open on Stellar. It was already settled or it expired.'; - } - if (isContractError(err, CONTRACT_ERROR.NotHeroOwner)) return 'This delver changed hands during the run, so it cannot be settled.'; - if ( - isContractError(err, CONTRACT_ERROR.RunIncomplete) || - isContractError(err, CONTRACT_ERROR.TrailingActions) || - isContractError(err, CONTRACT_ERROR.InvalidAction) - ) { - return 'The contract could not reproduce this run, so it was not recorded.'; - } - return 'Stellar could not be reached to settle this run.'; -} - -/** Sends the log, waiting out the contract's minimum play time and brief network trouble. */ -async function submitLog(finished: RunState): Promise { - for (let attempt = 0; ; attempt += 1) { - try { - return await enqueue(async () => { - const api = await chain(); - return api.finishRun(await api.getPlayerKeypair(), finished.actions); - }); - } catch (err) { - if (isPermanentFailure(err) || attempt >= VERIFY_ATTEMPTS - 1) throw err; - chainStatus = 'Waiting for Stellar to settle this run...'; - verifyState = 'waiting'; - await sleep(VERIFY_RETRY_MS); - } - } -} - -async function verifyOnChain(finished: RunState): Promise { - chainStatus = 'Verifying on Stellar...'; - verifyState = 'pending'; - canRetry = false; - let settled: FinishedRun; - try { - settled = await submitLog(finished); - } catch (err) { - console.warn('Deepmark: failed to verify this run on-chain', err); - chainStatus = describeFailure(err); - verifyState = 'failed'; - canRetry = !isPermanentFailure(err); - failedRun = finished; - // A refusal that waiting cannot fix means the run is gone for good, so there is nothing left to resume. - if (isPermanentFailure(err)) clearSavedRun(); - return; - } - - const { hash, outcome } = settled; - failedRun = null; - clearSavedRun(); - chainTx = hash; - chainHeroId = outcome.hero_id ?? null; - if (chainHeroId !== null) vaultHeroes = null; - const relicsMatch = outcome.relics_common + outcome.relics_rare + outcome.relics_core === finished.relicsThisRun; - const agrees = - outcome.floor_reached === finished.floorNumber && - outcome.score === finished.scoreThisRun && - outcome.hp_left === finished.player.hp && - relicsMatch; - if (!agrees) console.error('Deepmark: the contract result differs from the local simulation', outcome); - chainStatus = agrees ? 'Verified on Stellar' : 'Verified on Stellar (this browser disagreed, the chain result stands)'; - verifyState = 'verified'; - justVerifiedUntil = performance.now() + 4000; - if (chainHeroId !== null) window.setTimeout(sfxHeroEarned, 900); - - // The run is settled. Anything below only decorates the result screen, so a - // failure here must never turn a confirmed transaction into an error. - try { - await refreshOnchain(); - const api = await chain(); - const top = runWasVeteran ? await api.fetchTopVeteran() : await api.fetchTopFresh(); - const position = top.findIndex((entry) => entry.player === playerAddress); - chainRank = position >= 0 ? position + 1 : null; - leaderboardRows = null; - } catch (err) { - console.warn('Deepmark: could not refresh the leaderboard rank', err); - } -} - -/** A run that grows past what the contract accepts can no longer be verified. */ -function endExhaustedRun(state: RunState): void { - if (state.mode === 'result') return; - state.mode = 'result'; - state.resultKind = 'dead'; - state.relicsThisRun = 0; - state.scoreThisRun = 0; - practice = true; - clearSavedRun(); - chainStatus = 'This run grew too long to be verified.'; -} - -const DIRS: Partial> = { - up: [0, -1], - down: [0, 1], - left: [-1, 0], - right: [1, 0], -}; - -/** Books a finished run into this browser's own progress. The simulation stays free of storage so a log can be replayed safely. */ -function settleLocalProgress(finished: RunState): void { - const extracted = finished.resultKind === 'extracted'; - const result = recordRun({ - floorReached: finished.floorNumber, - relicsKept: finished.relicsThisRun, - score: finished.scoreThisRun, - extracted, - heroId: finished.heroId, - }); - progress = result.progress; - finished.newBest = result.isNewBest && extracted; -} - -/** Puts a run rebuilt from its saved log back on screen, at the point it was left. */ -function presentResumedRun(plan: Exclude, ticket: RunTicket): void { - const resumed = plan.run; - resetMotion(); - resetRelicTravel(); - canvas.classList.remove('board-sunk'); - chainStatus = ''; - chainTx = ''; - chainRank = null; - chainHeroId = null; - canRetry = false; - practice = false; - runWasVeteran = typeof ticket.veteran === 'number'; - displayedRelics = resumed.relicsThisRun; - run = resumed; - if (plan.kind === 'continue') { - startAmbience(resumed.floorNumber); - showBanner('Run restored', FLOOR_NAMES[resumed.floorNumber] ?? ''); - return; - } - // The run had already ended when the page went away: show its result and send the log. - runId += 1; - if (resumed.resultKind === 'dead') canvas.classList.add('board-sunk'); - else preloadShareAssets(HEROES[resumed.heroId].veteranArt, resumed.loadout); - void verifyOnChain(resumed); +function isLowHealth(): boolean { + const run = session.run; + if (!run || run.mode === 'result' || session.showLeaderboard || session.showStats) return false; + return run.player.hp / run.player.maxHp <= LOW_HEALTH_RATIO; } -let resumeChecked = false; - -/** - * If this browser holds a saved run, asks the contract whether that very run is - * still open and, if so, restores it. A saved run the contract no longer knows - * is dropped. If Stellar cannot be read the save is kept for the next visit. - */ -async function resumeOpenRun(): Promise { - if (resumeChecked || chainRead !== 'ready') return; - resumeChecked = true; - const saved = loadSavedRun(); - if (!saved) return; - try { - const api = await chain(); - const ticket = await api.fetchOpenRun(playerAddress); - if (run || starting) return; - const plan = planResume(saved, ticket); - if (plan.kind === 'discard' || !ticket) { - clearSavedRun(); - return; - } - presentResumedRun(plan, ticket); - } catch (err) { - console.warn('Deepmark: could not check for an open run', err); - } +function pulseHeartbeatWhenLow(): void { + if (!isLowHealth()) return; + const now = performance.now(); + if (now - session.lastHeartbeat <= HEARTBEAT_MS) return; + session.lastHeartbeat = now; + sfxHeartbeat(); } -function handleEvent(event: GameEvent): void { - switch (event.type) { - case 'enemyHit': { - if (run) { - playAttack('player', run.player.x, run.player.y); - const struck = run.enemies.findIndex((e) => e.x === event.x && e.y === event.y); - if (struck >= 0) flashActor(`enemy-${struck}`); - } - spawnPopup(event.x, event.y, `-${event.amount}`, '#e5e7eb'); - spawnBurst(event.x, event.y, 'hit'); - sfxHitEnemy(); - hitStop(event.amount >= 5 ? 90 : 55); - punch(Math.min(1, 0.25 + event.amount * 0.1)); - triggerShake(100 + event.amount * 12, 3 + event.amount); - break; - } - case 'playerHit': - if (run && run.activeEnemyIndex !== null) { - const enemy = run.enemies[run.activeEnemyIndex]; - if (enemy) playAttack(`enemy-${run.activeEnemyIndex}`, enemy.x, enemy.y); - } - spawnPopup(event.x, event.y, `-${event.amount}`, '#ef5350'); - flashActor('player'); - spawnBurst(event.x, event.y, 'hit'); - sfxHitPlayer(); - hitStop(80); - punch(0.5 + Math.min(0.5, event.amount * 0.1)); - triggerShake(150 + event.amount * 15, 5 + event.amount * 1.5); - break; - case 'enemyDied': - spawnBurst(event.x, event.y, 'kill'); - hitStop(120); - punch(0.8); - sfxEnemyDied(); - triggerShake(120, 5); - break; - case 'relic': { - spawnPopup(event.x, event.y, '+relic', '#ffca28'); - spawnBurst(event.x, event.y, 'relic'); - sfxRelic(event.kind); - const offsetX = (canvas.width - GRID_WIDTH * TILE_SIZE) / 2; - const startX = offsetX + event.x * TILE_SIZE + TILE_SIZE / 2; - const startY = event.y * TILE_SIZE + TILE_SIZE / 2; - const counter = document.getElementById('hud-relics'); - const canvasRect = canvas.getBoundingClientRect(); - if (counter && canvasRect.width > 0 && canvasRect.height > 0) { - // The counter sits below the board in this layout: the point aims at the counter's - // live horizontal position but can only ever reach the board's own bottom edge. - const counterRect = counter.getBoundingClientRect(); - const scaleX = canvas.width / canvasRect.width; - const scaleY = canvas.height / canvasRect.height; - const endX = Math.min(canvas.width, Math.max(0, (counterRect.left + counterRect.width / 2 - canvasRect.left) * scaleX)); - const endY = Math.min(canvas.height - 4, (counterRect.top - canvasRect.top) * scaleY); - spawnRelicTravel(startX, startY, endX, endY, event.kind); - } - break; - } - case 'floorCleared': - sfxFloorCleared(); - showHint('choice'); - if (run && run.floorNumber === MAX_FLOOR - 1) preloadBossArt(); - break; - case 'descended': - resetMotion(); - sfxDescend(); - startAmbience(event.floor); - if (event.floor >= MAX_FLOOR) { - const stage = document.querySelector('.stage'); - if (stage) { - bossIntro = playBossIntro( - stage, - 'Warden Prime', - () => sfxBossIntro(), - () => { - bossIntro = null; - } - ); - } else { - sfxBossIntro(); - } - } else { - showBanner(`Floor ${event.floor}`, FLOOR_NAMES[event.floor] ?? '', 'floor'); - sfxBanner(); - } - break; - case 'extracted': - stopAmbience(); - sfxExtracted(); - if (run) settleLocalProgress(run); - runId += 1; - if (run) { - const heroId = HERO_ORDER[run.loadout.base] ?? 'lantern'; - preloadShareAssets(HEROES[heroId].veteranArt, run.loadout); - } - if (run && !practice) void verifyOnChain(run); - break; - case 'dead': - stopAmbience(); - sfxDead(); - triggerShake(250, 10); - canvas.classList.add('board-sunk'); - if (run) settleLocalProgress(run); - runId += 1; - if (run && !practice) void verifyOnChain(run); - break; - case 'combatStart': - showHint('combat'); - break; - } -} - -function flashShareButton(action: 'shareSave' | 'shareCopy' | 'shareChallenge', label: string): void { - const btn = document.querySelector(`[data-action="${action}"]`); - if (!btn) return; - const original = btn.textContent ?? ''; - btn.textContent = label; - window.setTimeout(() => { - btn.textContent = original; - }, 1600); -} - -async function shareResult(target: RunState, mode: 'save' | 'copy'): Promise { - const heroId = HERO_ORDER[target.loadout.base] ?? 'lantern'; - const blob = await renderShareCard({ - name: target.look.name, - portraitArt: HEROES[heroId].veteranArt, - portraitFilter: cloakFilter(target.look.cloak, target.loadout.base), - weapon: target.loadout.weapon, - charm: target.loadout.charm, - floorReached: target.floorNumber, - maxFloor: MAX_FLOOR, - score: target.scoreThisRun, - relics: target.relicsThisRun, - rank: chainRank, - txHash: chainTx, - }); - if (!blob) return; - if (mode === 'copy' && typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { - try { - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); - flashShareButton('shareCopy', 'Copied'); - return; - } catch { - // Clipboard image writes are not supported everywhere (notably iOS Safari): fall back to a download. - } - } - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `deepmark-${target.look.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.png`; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 2000); - flashShareButton(mode === 'copy' ? 'shareCopy' : 'shareSave', 'Saved'); -} - -async function copyChallengeLink(target: RunState): Promise { - const url = challengeUrl(target.seed, target.loadout); - try { - await navigator.clipboard.writeText(url); - flashShareButton('shareChallenge', 'Copied'); - } catch { - // No clipboard permission: the button simply does not confirm. - } -} - -/** Hovering Descend on the bank-or-push prompt: the heartbeat quickens the longer it holds. */ -let descendHovering = false; -let descendHoverTimer: ReturnType | null = null; - -function scheduleDescendBeat(delay: number): void { - descendHoverTimer = setTimeout(() => { - if (!descendHovering) return; - sfxHeartbeat(); - scheduleDescendBeat(Math.max(220, delay * 0.82)); - }, delay); -} - -function onDescendHover(hovering: boolean): void { - if (hovering === descendHovering) return; - descendHovering = hovering; - if (hovering) { - sfxHeartbeat(); - scheduleDescendBeat(650); - } else if (descendHoverTimer) { - clearTimeout(descendHoverTimer); - descendHoverTimer = null; - } -} - -const MOVE_CODES = { - up: ActionCode.Up, - down: ActionCode.Down, - left: ActionCode.Left, - right: ActionCode.Right, -} as const; - -function syncMuteButton(): void { - const button = document.getElementById('mute'); - if (!button) return; - const muted = isMuted(); - button.textContent = muted ? 'Sound off' : 'Sound on'; - button.setAttribute('aria-pressed', muted ? 'true' : 'false'); -} - -syncMuteButton(); - -onAction((action) => { - if (action === 'mute') { - toggleMuted(); - syncMuteButton(); - return; - } - if (!run) sfxUi(); - if (showStats) { - if (action === 'stats' || action === 'cancel') showStats = false; - if (action === 'replayHints') resetHints(); - return; - } - - if (showLeaderboard) { - if (action === 'leaderboard' || action === 'cancel') showLeaderboard = false; - if (action === 'tabFresh' || action === 'tabVeteran') { - boardTab = action === 'tabVeteran' ? 'veteran' : 'fresh'; - void loadBoard(); - } - return; - } - - if (starting) return; - - if (bossIntro) { - bossIntro.skip(); - return; - } - - if (showCreator) { - if (action === 'cancel') showCreator = false; - return; - } - - if (showVault) { - if (action === 'vault' || action === 'cancel') showVault = false; - return; - } - - if (!run) { - if (action === 'confirm') { - if (pendingChallengeDraft) { - pendingChallengeDraft = false; - } else { - draft = loadDraft(); - } - showCreator = true; - } - if (action === 'vault') void openVault(); - if (action === 'stats') showStats = true; - if (action === 'leaderboard') void openLeaderboard(); - return; - } - - if (run.mode === 'result') { - const resultRun = run; - if (action === 'confirm') { - resetMotion(); - run = null; - chainStatus = ''; - chainTx = ''; - chainRank = null; - canRetry = false; - } - if (action === 'retryVerify' && failedRun) void verifyOnChain(failedRun); - if (action === 'shareSave' || action === 'shareCopy') void shareResult(resultRun, action === 'shareCopy' ? 'copy' : 'save'); - if (action === 'shareChallenge') void copyChallengeLink(resultRun); - if (action === 'vault') { - resetMotion(); - run = null; - chainStatus = ''; - chainTx = ''; - chainRank = null; - canRetry = false; - void openVault(); - } - return; - } - - const actionsBefore = run.actions.length; - if (run.mode === 'combat') { - if (action === 'confirm') applyAction(run, ActionCode.Attack); - if (action === 'cancel') applyAction(run, ActionCode.Retreat); - noteHintInput(action); - } else if (run.mode === 'choice') { - if (action === 'confirm') applyAction(run, ActionCode.Descend); - if (action === 'cancel') applyAction(run, ActionCode.Extract); - noteHintInput(action); - } else { - const dir = DIRS[action]; - if (dir) { - if (dir[0] !== 0) face(dir[0] < 0 ? -1 : 1); - const moved = applyAction(run, MOVE_CODES[action as keyof typeof MOVE_CODES]); - if (moved && run.mode === 'explore') sfxStep(); - noteHintInput(action); - } - } - - if (run.exhausted) endExhaustedRun(run); - if (!practice && run.actions.length !== actionsBefore) saveRun(run); - for (const event of drainEvents(run)) handleEvent(event); -}); - -function frame(): void { - if (run && run.mode !== 'result' && !showLeaderboard && !showStats && run.player.hp / run.player.maxHp <= LOW_HEALTH) { - const now = performance.now(); - if (now - lastHeartbeat > HEARTBEAT_MS) { - lastHeartbeat = now; - sfxHeartbeat(); - } - } - const sunkDefeat = run && run.mode === 'result' && run.resultKind !== 'extracted' && !showLeaderboard && !showStats; - if (run && !showLeaderboard && !showStats && run.mode !== 'result') drawWorld(ctx, run); +function paintBoard(): void { + const { run, showLeaderboard, showStats } = session; + const covered = showLeaderboard || showStats; + const sunkDefeat = run && run.mode === 'result' && run.resultKind !== 'extracted' && !covered; + if (run && !covered && run.mode !== 'result') drawWorld(ctx, run); else if (!sunkDefeat) { - ctx.fillStyle = '#07080e'; + ctx.fillStyle = BACKDROP_COLOR; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); } +} + +/** Lets each relic's travelling point land on the counter, then bumps the counter for a core relic. */ +function landArrivedRelics(): void { for (const kind of drainArrivedRelics()) { - displayedRelics += 1; + session.displayedRelics += 1; sfxRelicArrive(kind); - if (kind === 'core') { - window.setTimeout(() => { - const node = document.getElementById('hud-relics'); - if (!node) return; - node.classList.remove('bump'); - void node.offsetWidth; - node.classList.add('bump'); - }, 140); - } + if (kind !== 'core') continue; + window.setTimeout(() => { + const node = document.getElementById('hud-relics'); + if (!node) return; + node.classList.remove('bump'); + void node.offsetWidth; + node.classList.add('bump'); + }, CORE_BUMP_DELAY_MS); } +} + +function syncView(): void { + const s = session; syncChrome({ - showCreator, - showVault, - draft, + showCreator: s.showCreator, + showVault: s.showVault, + draft: s.draft, creatorHooks: { onBegin: (chosen) => { - draft = chosen; - showCreator = false; + s.draft = chosen; + s.showCreator = false; void beginRun(planFresh(chosen)); }, onBack: () => { - showCreator = false; + s.showCreator = false; }, }, - vaultHeroes, - vaultLoading, + vaultHeroes: s.vaultHeroes, + vaultLoading: s.vaultLoading, onVeteran: (id) => { - const hero = vaultHeroes?.find((h) => h.id === id); + const hero = s.vaultHeroes?.find((h) => h.id === id); if (!hero) return; - showVault = false; + s.showVault = false; void beginRun(planVeteran(hero)); }, - boardTab, - chainHeroId, - canRetry, - verifyState, - runId, - showLeaderboard, - showStats, - leaderboardLoading, - leaderboardRows, - displayRelics: displayedRelics, - justVerified: performance.now() < justVerifiedUntil, - playerAddress, - run, - progress, + boardTab: s.boardTab, + chainHeroId: s.chainHeroId, + chainClears: s.chainClears, + runWasVeteran: s.runWasVeteran, + canRetry: s.canRetry, + verifyState: s.verifyState, + runId: s.runId, + showLeaderboard: s.showLeaderboard, + showStats: s.showStats, + leaderboardLoading: s.leaderboardLoading, + leaderboardRows: s.leaderboardRows, + leaderboardFailed: s.leaderboardFailed, + recoveryNote: s.recoveryNote, + restoreStep: s.restoreStep, + restoreError: s.restoreError, + restoreTarget: s.restoreTarget, + displayRelics: s.displayedRelics, + justVerified: performance.now() < s.justVerifiedUntil, + playerAddress: s.playerAddress, + run: s.run, + progress: s.progress, onchain: onchainSnapshot(), - relics: relicBalances, - chainRead, - chainStatus, - chainTx, - chainRank, - starting, - startStage, - descent, - practice, + relics: s.relicBalances, + chainRead: s.chainRead, + chainStatus: s.chainStatus, + chainTx: s.chainTx, + chainRank: s.chainRank, + starting: s.starting, + startStage: s.startStage, + descent: s.descent, + practice: s.practice, onDescendHover, onSeal: sfxSeal, }); - if (run?.mode !== 'choice' && descendHovering) onDescendHover(false); +} + +function frame(): void { + const { run } = session; + if (run?.mode === 'result' && session.resultShownRunId !== session.runId) { + session.resultShownRunId = session.runId; + session.resultShownAt = performance.now(); + } + pulseHeartbeatWhenLow(); + paintBoard(); + landArrivedRelics(); + syncView(); + if (session.run?.mode !== 'choice' && isDescendHovering()) onDescendHover(false); requestAnimationFrame(frame); } diff --git a/client/src/game/delverOutcome.ts b/client/src/game/delverOutcome.ts new file mode 100644 index 0000000..a39462f --- /dev/null +++ b/client/src/game/delverOutcome.ts @@ -0,0 +1,21 @@ +/** + * What a verified full clear means for the delver, in words the result screen + * can trust. The contract reports a `hero_id` for both kinds of clear: a fresh + * delver is minted, while a veteran only gains a clear on the delver it + * already owns. + */ +export type DelverOutcome = { kind: 'none' } | { kind: 'earned' } | { kind: 'grew'; clears: number | null }; + +export function delverOutcome(heroId: number | null, veteranRun: boolean, clears: number | null): DelverOutcome { + if (heroId === null) return { kind: 'none' }; + if (!veteranRun) return { kind: 'earned' }; + return { kind: 'grew', clears }; +} + +export const DELVER_EARNED_KICKER = 'A new delver is yours'; + +/** The kicker for a veteran whose record grew, with the new clear count when it is known. */ +export function delverGrewKicker(clears: number | null): string { + if (clears === null) return "Your delver's record grew"; + return `Your delver's record grew: ${clears} ${clears === 1 ? 'clear' : 'clears'}`; +} diff --git a/client/src/game/draft.ts b/client/src/game/draft.ts index c8b58f6..a72c2e3 100644 --- a/client/src/game/draft.ts +++ b/client/src/game/draft.ts @@ -2,6 +2,7 @@ import { CHARMS, WEAPONS } from './items'; import { HERO_ORDER } from './heroes'; import { RECOMMENDED_KIT } from './kitDifficulty'; import { CLOAK_COUNT, EMBLEM_COUNT, isValidName, type Loadout, type Look } from './loadout'; +import { readJson, writeJson } from '../storage'; /** A delver being built in the creator, kept in this browser until a run starts. */ export interface Draft { @@ -41,24 +42,12 @@ function sanitize(raw: unknown): Draft | null { } export function loadDraft(): Draft { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const draft = sanitize(JSON.parse(raw)); - if (draft) return draft; - } - } catch { - // Private mode or corrupt data: fall through to a fresh draft. - } - return defaultDraft(); + // Missing or corrupt data falls through to a fresh draft. + return sanitize(readJson(STORAGE_KEY)) ?? defaultDraft(); } export function saveDraft(draft: Draft): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); - } catch { - // The draft still lasts for this tab. - } + writeJson(STORAGE_KEY, draft); } export function loadoutOf(draft: Draft): Loadout { diff --git a/client/src/game/items.ts b/client/src/game/items.ts index d509313..991eaed 100644 --- a/client/src/game/items.ts +++ b/client/src/game/items.ts @@ -21,13 +21,37 @@ export interface Item extends Modifier { } export const WEAPONS: readonly Item[] = [ - { id: 0, name: 'Plumb Blade', icon: 'plumb-blade.webp', blurb: 'A sharper floor on every strike, at the cost of a thinner coat.', hp: -2, dmgMin: 1, dmgMax: 0 }, + { + id: 0, + name: 'Plumb Blade', + icon: 'plumb-blade.webp', + blurb: 'A sharper floor on every strike, at the cost of a thinner coat.', + hp: -2, + dmgMin: 1, + dmgMax: 0, + }, { id: 1, name: 'Reaver Hook', icon: 'reaver-hook.webp', blurb: 'Wild swings that can end a fight early.', hp: -2, dmgMin: 0, dmgMax: 1 }, - { id: 2, name: 'Bulwark Pike', icon: 'bulwark-pike.webp', blurb: 'A reach that keeps you standing, with softer blows.', hp: 1, dmgMin: 0, dmgMax: -1 }, + { + id: 2, + name: 'Bulwark Pike', + icon: 'bulwark-pike.webp', + blurb: 'A reach that keeps you standing, with softer blows.', + hp: 1, + dmgMin: 0, + dmgMax: -1, + }, ]; export const CHARMS: readonly Item[] = [ - { id: 0, name: 'Oil Flask', icon: 'oil-flask.webp', blurb: 'Slick and unsteady: hits vary more and you are a little sturdier.', hp: 1, dmgMin: -1, dmgMax: 1 }, + { + id: 0, + name: 'Oil Flask', + icon: 'oil-flask.webp', + blurb: 'Slick and unsteady: hits vary more and you are a little sturdier.', + hp: 1, + dmgMin: -1, + dmgMax: 1, + }, { id: 1, name: 'Keen Eye', icon: 'keen-eye.webp', blurb: 'Hits land far harder. You feel every wound.', hp: -3, dmgMin: 1, dmgMax: 2 }, { id: 2, name: 'Stone Heart', icon: 'stone-heart.webp', blurb: 'A small, dependable boost to endurance.', hp: 1, dmgMin: 0, dmgMax: 0 }, ]; diff --git a/client/src/game/kitDifficulty.ts b/client/src/game/kitDifficulty.ts index a5bde88..112c715 100644 --- a/client/src/game/kitDifficulty.ts +++ b/client/src/game/kitDifficulty.ts @@ -33,16 +33,16 @@ export function difficultyOf(percent: number): Difficulty { function bestKit(): Kit { let best: Kit = { base: 0, weapon: 0, charm: 0 }; let bestPercent = -1; - KIT_CLEAR_PERCENT.forEach((weapons, base) => - weapons.forEach((charms, weapon) => + KIT_CLEAR_PERCENT.forEach((weapons, base) => { + weapons.forEach((charms, weapon) => { charms.forEach((percent, charm) => { if (percent > bestPercent) { best = { base, weapon, charm }; bestPercent = percent; } - }) - ) - ); + }); + }); + }); return best; } diff --git a/client/src/game/progress.ts b/client/src/game/progress.ts index 75d9a4b..6e45f19 100644 --- a/client/src/game/progress.ts +++ b/client/src/game/progress.ts @@ -1,5 +1,6 @@ import { MAX_FLOOR } from './floor'; import type { HeroId } from './heroes'; +import { readJson, writeJson } from '../storage'; export interface Progress { bestFloor: number; @@ -49,22 +50,14 @@ function sanitize(raw: Partial | null): Progress { } export function loadProgress(): Progress { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return { ...EMPTY }; - return sanitize(JSON.parse(raw) as Partial); - } catch { - return { ...EMPTY }; - } + const stored = readJson(STORAGE_KEY); + if (!stored || typeof stored !== 'object') return { ...EMPTY }; + return sanitize(stored as Partial); } function saveProgress(progress: Progress): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); - } catch { - // localStorage unavailable (private mode, quota, etc). Progression just - // won't persist across reloads this session; the run itself still works. - } + // When storage is unavailable progression just does not persist; the run itself still works. + writeJson(STORAGE_KEY, progress); } export interface FinishedRun { @@ -73,6 +66,8 @@ export interface FinishedRun { score: number; extracted: boolean; heroId: HeroId; + /** A practice run is not recorded anywhere, so it never counts toward this browser's bests. */ + practice: boolean; } function bump(value: number, amount: number): number { @@ -81,6 +76,7 @@ function bump(value: number, amount: number): number { export function recordRun(finished: FinishedRun): { progress: Progress; isNewBest: boolean } { const previous = loadProgress(); + if (finished.practice) return { progress: previous, isNewBest: false }; const floorReached = count(finished.floorReached, MAX_FLOOR); const relicsKept = finished.extracted ? count(finished.relicsKept) : 0; const score = finished.extracted ? count(finished.score) : 0; diff --git a/client/src/game/recordNudge.ts b/client/src/game/recordNudge.ts new file mode 100644 index 0000000..fac3d16 --- /dev/null +++ b/client/src/game/recordNudge.ts @@ -0,0 +1,12 @@ +export interface NudgeFacts { + practice: boolean; + verified: boolean; + won: boolean; + /** The account's verified run count on chain, or null while it is unknown. */ + verifiedRuns: number | null; +} + +/** The calm "keep your record" line: only after a fresh account's first run is verified, and only if it was a win. */ +export function shouldNudgeRecord(facts: NudgeFacts): boolean { + return !facts.practice && facts.verified && facts.won && facts.verifiedRuns === 1; +} diff --git a/client/src/game/savedRun.ts b/client/src/game/savedRun.ts index b8df5f8..5eb6047 100644 --- a/client/src/game/savedRun.ts +++ b/client/src/game/savedRun.ts @@ -1,5 +1,6 @@ import { isValidLoadout, type Loadout, type Look } from './loadout'; import { ActionCode, applyAction, drainEvents, MAX_ACTIONS, newRun, type RunState } from './state'; +import { readItem, removeItem, writeItem } from '../storage'; /** * A run the contract has opened but not yet settled, kept in this browser so a @@ -75,6 +76,7 @@ export function parseSavedRun(raw: string): SavedRun | null { try { value = JSON.parse(raw); } catch { + // Not JSON at all, so not a run this browser saved. return null; } if (!value || typeof value !== 'object') return null; @@ -97,28 +99,17 @@ export function serializeSavedRun(run: RunState): string { } export function saveRun(run: RunState): void { - try { - localStorage.setItem(STORAGE_KEY, serializeSavedRun(run)); - } catch { - // Storage is unavailable (private mode, quota): the run still plays, it just cannot resume after a reload. - } + // When storage is unavailable the run still plays, it just cannot resume after a reload. + writeItem(STORAGE_KEY, serializeSavedRun(run)); } export function loadSavedRun(): SavedRun | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? parseSavedRun(raw) : null; - } catch { - return null; - } + const raw = readItem(STORAGE_KEY); + return raw ? parseSavedRun(raw) : null; } export function clearSavedRun(): void { - try { - localStorage.removeItem(STORAGE_KEY); - } catch { - // Nothing was stored, or storage is unavailable. Either way there is nothing to clear. - } + removeItem(STORAGE_KEY); } /** diff --git a/client/src/input/guard.ts b/client/src/input/guard.ts new file mode 100644 index 0000000..440d933 --- /dev/null +++ b/client/src/input/guard.ts @@ -0,0 +1,10 @@ +/** + * Enter presses left over from combat must not dismiss the result screen the + * moment it appears, so confirm is ignored for this long after it shows. + */ +export const RESULT_INPUT_GUARD_MS = 700; + +/** True while input aimed at a freshly shown result screen should still be ignored. */ +export function resultGuardActive(shownAt: number, now: number): boolean { + return now - shownAt < RESULT_INPUT_GUARD_MS; +} diff --git a/client/src/input/keyboard.ts b/client/src/input/keyboard.ts index cb4240f..8ea716e 100644 --- a/client/src/input/keyboard.ts +++ b/client/src/input/keyboard.ts @@ -10,6 +10,14 @@ export type Action = | 'vault' | 'mute' | 'retryVerify' + | 'retryBoard' + | 'retryVault' + | 'again' + | 'copyRecovery' + | 'restoreRecovery' + | 'restoreCheck' + | 'restoreConfirm' + | 'restoreCancel' | 'tabFresh' | 'tabVeteran' | 'pickLantern' @@ -49,6 +57,14 @@ const ACTIONS: readonly Action[] = [ 'vault', 'mute', 'retryVerify', + 'retryBoard', + 'retryVault', + 'again', + 'copyRecovery', + 'restoreRecovery', + 'restoreCheck', + 'restoreConfirm', + 'restoreCancel', 'tabFresh', 'tabVeteran', 'pickLantern', diff --git a/client/src/loader.css b/client/src/loader.css new file mode 100644 index 0000000..a9d5147 --- /dev/null +++ b/client/src/loader.css @@ -0,0 +1,75 @@ +/* Shared by the game and the stats page: both define --cyan, --line and --muted. */ +.loader { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 12px 0; +} + +.loader-mark { + width: 56px; + height: 56px; + display: block; + animation: loader-breathe 2.4s ease-in-out infinite; +} + +.loader-mark .seal-icon { + width: 100%; + height: 100%; + display: block; + filter: drop-shadow(0 0 10px rgba(126, 240, 224, 0.4)); +} + +.loader-mark .seal-outer, +.loader-mark .seal-inner, +.loader-mark .seal-eye { + fill: none; + stroke: var(--cyan); + stroke-width: 2; + stroke-linejoin: round; +} + +.loader-mark .seal-pupil { fill: var(--cyan); } + +.loader-text { + margin: 0; + color: var(--muted); + font-size: 0.92rem; + letter-spacing: 0.04em; + text-align: center; +} + +.loader-rows { + align-self: stretch; + display: flex; + flex-direction: column; + gap: 10px; +} + +.loader-row { + display: block; + height: 44px; + border-radius: 12px; + border: 1px solid var(--line); + background: linear-gradient(90deg, rgba(232, 224, 208, 0.04) 20%, rgba(126, 240, 224, 0.1) 50%, rgba(232, 224, 208, 0.04) 80%) 0 0 / 200% 100%; + animation: loader-sweep 1.8s ease-in-out infinite; +} + +.loader-row:nth-child(2) { animation-delay: 150ms; } +.loader-row:nth-child(3) { animation-delay: 300ms; } + +@keyframes loader-breathe { + 0%, 100% { opacity: 0.55; transform: scale(0.96); } + 50% { opacity: 1; transform: scale(1); } +} + +@keyframes loader-sweep { + from { background-position: 100% 0; } + to { background-position: -100% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .loader-mark { animation: none; } + .loader-row { animation: none; } +} diff --git a/client/src/main.ts b/client/src/main.ts index 7e525bb..a3153ea 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -34,7 +34,7 @@ if (previewDescent) { } }); } else if (previewResult) { - // http://localhost:5173/?preview=result&hero=1&loss=1 + // http://localhost:5173/?preview=result&hero=1 (new delver) or hero=vet (veteran clear) &loss=1 const params = new URLSearchParams(window.location.search); // Mounted in the real shell, so landmarks and styles match the game. const host = document.getElementById('overlay') ?? document.body; @@ -60,6 +60,7 @@ if (previewDescent) { rank: 2, txUrl: 'https://stellar.expert/explorer/testnet', heroEarned: params.get('hero') === '1', + delverGrew: params.get('hero') === 'vet' ? { clears: 3 } : null, canRetry: false, }); }, 4500); diff --git a/client/src/preview/screens.ts b/client/src/preview/screens.ts index aec698a..4c06ac5 100644 --- a/client/src/preview/screens.ts +++ b/client/src/preview/screens.ts @@ -5,7 +5,9 @@ * ?preview=vault a vault with two earned delvers * ?preview=leaderboard a leaderboard with the player in second place */ -import { leaderboardCard, vaultCard, type LeaderboardRow } from '../render/chrome'; +import type { LeaderboardRow } from '../render/chromeTypes'; +import { leaderboardCard } from '../render/leaderboardCard'; +import { vaultCard } from '../render/vaultCard'; import type { Hero } from '../stellar/contract'; const SAMPLE_PLAYER = 'GCDEEPMARKSAMPLEPLAYERADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAB'; @@ -40,9 +42,25 @@ const SAMPLE_HEROES: Hero[] = [ ]; const SAMPLE_ROWS: LeaderboardRow[] = [ - { address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', name: 'Corvin', emblem: 0, score: 7, floor: 3, hpLeft: 4, actions: 98 }, + { + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + name: 'Corvin', + emblem: 0, + score: 7, + floor: 3, + hpLeft: 4, + actions: 98, + }, { address: SAMPLE_PLAYER, name: 'Brannoch', emblem: 1, score: 6, floor: 3, hpLeft: 3, actions: 112 }, - { address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', name: 'Isolde', emblem: 3, score: 4, floor: 2, hpLeft: 0, actions: 131 }, + { + address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + name: 'Isolde', + emblem: 3, + score: 4, + floor: 2, + hpLeft: 0, + actions: 131, + }, ]; export type ScreenPreview = 'vault' | 'leaderboard'; @@ -62,6 +80,7 @@ export function mountScreenPreview(kind: ScreenPreview): void { : leaderboardCard({ boardTab: 'fresh', leaderboardLoading: false, + leaderboardFailed: false, leaderboardRows: SAMPLE_ROWS, playerAddress: SAMPLE_PLAYER, justVerified: false, diff --git a/client/src/render/audio.ts b/client/src/render/audio.ts index b086224..c0356da 100644 --- a/client/src/render/audio.ts +++ b/client/src/render/audio.ts @@ -3,12 +3,14 @@ * download, nothing to license. A quiet ambience per floor, soft footsteps, * hits with a little variation each time, and bell tones for relics. */ +import { readItem, writeItem } from '../storage'; + const MUTE_KEY = 'deepmark:muted:v1'; const FX_VOLUME_KEY = 'deepmark:vol:fx:v1'; const AMBIENCE_VOLUME_KEY = 'deepmark:vol:ambience:v1'; const MASTER_LEVEL = 0.9; /** A ceremony sound ducks the ambience by about 6 dB for its length. */ -const DUCK_FACTOR = Math.pow(10, -6 / 20); +const DUCK_FACTOR = 10 ** (-6 / 20); let ctx: AudioContext | null = null; let master: GainNode | null = null; @@ -22,22 +24,14 @@ let fxVolume = readVolume(FX_VOLUME_KEY); let ambienceVolume = readVolume(AMBIENCE_VOLUME_KEY); function readMuted(): boolean { - try { - return localStorage.getItem(MUTE_KEY) === '1'; - } catch { - return false; - } + return readItem(MUTE_KEY) === '1'; } function readVolume(key: string): number { - try { - const raw = localStorage.getItem(key); - if (raw === null) return 1; - const value = Number(raw); - return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1; - } catch { - return 1; - } + const raw = readItem(key); + if (raw === null) return 1; + const value = Number(raw); + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1; } /** @@ -107,11 +101,7 @@ export function isMuted(): boolean { export function toggleMuted(): boolean { muted = !muted; - try { - localStorage.setItem(MUTE_KEY, muted ? '1' : '0'); - } catch { - // The choice still holds for this tab. - } + writeItem(MUTE_KEY, muted ? '1' : '0'); const audio = getContext(); if (audio && master) master.gain.setTargetAtTime(muted ? 0 : MASTER_LEVEL, audio.currentTime, 0.05); return muted; @@ -127,22 +117,14 @@ export function getAmbienceVolume(): number { export function setFxVolume(value: number): void { fxVolume = Math.min(1, Math.max(0, value)); - try { - localStorage.setItem(FX_VOLUME_KEY, String(fxVolume)); - } catch { - // The choice still holds for this tab. - } + writeItem(FX_VOLUME_KEY, String(fxVolume)); const audio = getContext(); if (audio && fxGain) fxGain.gain.setTargetAtTime(fxVolume, audio.currentTime, 0.05); } export function setAmbienceVolume(value: number): void { ambienceVolume = Math.min(1, Math.max(0, value)); - try { - localStorage.setItem(AMBIENCE_VOLUME_KEY, String(ambienceVolume)); - } catch { - // The choice still holds for this tab. - } + writeItem(AMBIENCE_VOLUME_KEY, String(ambienceVolume)); const audio = getContext(); if (audio && ambienceGain) ambienceGain.gain.setTargetAtTime(ambienceVolume, audio.currentTime, 0.05); } @@ -227,7 +209,11 @@ function bell(freq: number, gainLevel: number, delay = 0, decay = 1.1, destinati const dest = destination ?? out(); if (!audio || !dest) return; const t = audio.currentTime + delay; - for (const [ratio, level] of [[1, 1], [2.76, 0.45], [5.4, 0.2]] as const) { + for (const [ratio, level] of [ + [1, 1], + [2.76, 0.45], + [5.4, 0.2], + ] as const) { const osc = audio.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq * ratio, t); @@ -357,7 +343,11 @@ function makeBed(floor: number): Bed | null { const stoppers: (() => void)[] = []; - for (const [freq, gainValue] of [[spec.root, 0.07], [spec.root * 1.003, 0.05], [spec.tension, 0.025]] as const) { + for (const [freq, gainValue] of [ + [spec.root, 0.07], + [spec.root * 1.003, 0.05], + [spec.tension, 0.025], + ] as const) { const osc = audio.createOscillator(); osc.type = 'sine'; osc.frequency.value = freq; @@ -373,7 +363,10 @@ function makeBed(floor: number): Bed | null { gain.connect(level); osc.start(); lfo.start(); - stoppers.push(() => osc.stop(), () => lfo.stop()); + stoppers.push( + () => osc.stop(), + () => lfo.stop() + ); } const wind = audio.createBufferSource(); @@ -396,7 +389,10 @@ function makeBed(floor: number): Bed | null { windGain.connect(level); wind.start(); sweep.start(); - stoppers.push(() => wind.stop(), () => sweep.stop()); + stoppers.push( + () => wind.stop(), + () => sweep.stop() + ); return { stop: () => { diff --git a/client/src/render/bossIntro.ts b/client/src/render/bossIntro.ts index 0c0d6f1..befa611 100644 --- a/client/src/render/bossIntro.ts +++ b/client/src/render/bossIntro.ts @@ -69,7 +69,7 @@ export function playBossIntro(host: HTMLElement, name: string, onSting: () => vo function finish(): void { if (done) return; done = true; - timers.forEach((timer) => window.clearTimeout(timer)); + for (const timer of timers) window.clearTimeout(timer); scene.remove(); onDone(); } diff --git a/client/src/render/canvas.ts b/client/src/render/canvas.ts index 101623a..953e1a6 100644 --- a/client/src/render/canvas.ts +++ b/client/src/render/canvas.ts @@ -74,7 +74,15 @@ function biome(floor: number): { floor: HTMLImageElement; wall: HTMLImageElement return { floor: art.floor, wall: art.wall, stairs: art.stairs }; } -function pickFrame(idle: HTMLImageElement, walkA: HTMLImageElement, walkB: HTMLImageElement, attack: HTMLImageElement, clip: Clip, phase: number, shift: boolean): HTMLImageElement { +function pickFrame( + idle: HTMLImageElement, + walkA: HTMLImageElement, + walkB: HTMLImageElement, + attack: HTMLImageElement, + clip: Clip, + phase: number, + shift: boolean +): HTMLImageElement { if (clip === 'attack') { if (ready(attack)) return attack; if (ready(walkA)) return walkA; @@ -125,14 +133,7 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe ctx.closePath(); } -function drawBar( - ctx: CanvasRenderingContext2D, - cx: number, - topY: number, - width: number, - ratio: number, - lowThreshold: number -): void { +function drawBar(ctx: CanvasRenderingContext2D, cx: number, topY: number, width: number, ratio: number, lowThreshold: number): void { const height = 5; roundRect(ctx, cx - width / 2, topY, width, height, 2); ctx.fillStyle = COLOR_HPBACK; @@ -144,30 +145,16 @@ function drawBar( ctx.fill(); } -function blitCell( - ctx: CanvasRenderingContext2D, - image: HTMLImageElement, - x: number, - y: number, - dx: number, - dy: number -): void { +function blitCell(ctx: CanvasRenderingContext2D, image: HTMLImageElement, x: number, y: number, dx: number, dy: number): void { const sx = (x / GRID_WIDTH) * image.width; const sy = (y / GRID_HEIGHT) * image.height; - ctx.drawImage( - image, - sx, - sy, - image.width / GRID_WIDTH, - image.height / GRID_HEIGHT, - dx, - dy, - TILE_SIZE, - TILE_SIZE - ); + ctx.drawImage(image, sx, sy, image.width / GRID_WIDTH, image.height / GRID_HEIGHT, dx, dy, TILE_SIZE, TILE_SIZE); } -function playerFrames(heroId: HeroId, veteran: boolean): { +function playerFrames( + heroId: HeroId, + veteran: boolean +): { idle: HTMLImageElement; walkA: HTMLImageElement; walkB: HTMLImageElement; @@ -209,13 +196,7 @@ function drawFooted( ctx.restore(); } -function drawCentered( - ctx: CanvasRenderingContext2D, - image: HTMLImageElement, - cx: number, - cy: number, - size: number -): void { +function drawCentered(ctx: CanvasRenderingContext2D, image: HTMLImageElement, cx: number, cy: number, size: number): void { ctx.drawImage(image, cx - size / 2, cy - size / 2, size, size); } @@ -276,13 +257,7 @@ function paintBoard(ctx: CanvasRenderingContext2D, run: RunState, offsetX: numbe } } -function shadeOpenEdge( - ctx: CanvasRenderingContext2D, - dx: number, - dy: number, - side: 'n' | 's' | 'e' | 'w', - blocked: boolean -): void { +function shadeOpenEdge(ctx: CanvasRenderingContext2D, dx: number, dy: number, side: 'n' | 's' | 'e' | 'w', blocked: boolean): void { if (!blocked) return; const depth = 16; let gradient: CanvasGradient; @@ -451,19 +426,9 @@ export function drawWorld(ctx: CanvasRenderingContext2D, run: RunState): void { const playerPose = sampleActor('player', run.player.x, run.player.y); const player = cell(playerPose.x, playerPose.y); const sheet = playerFrames(run.heroId, run.loadout.rank > 0); - const playerImage = pickFrame( - sheet.idle, - sheet.walkA, - sheet.walkB, - sheet.attack, - playerPose.clip, - playerPose.phase, - false - ); + const playerImage = pickFrame(sheet.idle, sheet.walkA, sheet.walkB, sheet.attack, playerPose.clip, playerPose.phase, false); const playerLunge = playerPose.clip === 'attack' ? Math.sin(playerPose.phase * Math.PI) * 12 * facing : 0; - const squat = run.heroId === 'cinder' && playerPose.clip === 'walk' - ? 1 - Math.sin(playerPose.phase * Math.PI) * 0.08 - : 1; + const squat = run.heroId === 'cinder' && playerPose.clip === 'walk' ? 1 - Math.sin(playerPose.phase * Math.PI) * 0.08 : 1; if (ready(playerImage)) { // The cloak colour recolours the delver. Browsers without canvas filters just draw the base art. const cloak = cloakFilter(run.look.cloak, run.loadout.base); @@ -471,8 +436,7 @@ export function drawWorld(ctx: CanvasRenderingContext2D, run: RunState): void { ctx.filter = cloak === 'none' ? flash.trim() || 'none' : `${cloak}${flash}`; drawFooted(ctx, playerImage, player.cx + playerLunge, player.cy, ACTOR_SIZE.player, facing < 0, squat); ctx.filter = 'none'; - } - else { + } else { ctx.fillStyle = '#4fc3f7'; ctx.beginPath(); ctx.arc(player.cx, player.cy, 16, 0, Math.PI * 2); diff --git a/client/src/render/chrome.ts b/client/src/render/chrome.ts index 126306f..d1ff665 100644 --- a/client/src/render/chrome.ts +++ b/client/src/render/chrome.ts @@ -1,85 +1,22 @@ -import { ENEMIES } from '../game/enemies'; -import { buildFloor, MAX_FLOOR, type RelicKind } from '../game/floor'; +import { MAX_FLOOR } from '../game/floor'; import { HEROES, HERO_ORDER } from '../game/heroes'; -import type { Progress } from '../game/progress'; import type { RunState } from '../game/state'; -import type { Draft } from '../game/draft'; -import { CHARMS, WEAPONS } from '../game/items'; -import { MAX_RANK } from '../game/loadout'; +import { LOW_HEALTH_RATIO } from '../constants'; import { STELLAR_CONFIG } from '../stellar/config'; -import type { Hero } from '../stellar/contract'; -import { getAmbienceVolume, getFxVolume, setAmbienceVolume, setFxVolume } from './audio'; -import { mountCreator, type CreatorHooks } from './creator'; -import { cloakFilter, emblemIcon } from './emblems'; -import { mountResult, updateVerification, type ResultSummary, type Verification, type VerifyState } from './result'; -import { mountDescent, setDescentStage, type DescentOptions, type DescentStage } from './descent'; -import { mountTitle, updateTitle, type TitleInfo } from './title'; import type { RelicBalances } from '../stellar/relics'; - -export interface LeaderboardRow { - address: string; - name: string; - emblem: number; - score: number; - floor: number; - hpLeft: number; - actions: number; -} - -export type ChainRead = 'loading' | 'ready' | 'unavailable'; - -export interface OnchainSnapshot { - bestFloor: number; - bestScore: number; - runs: number; - extractions: number; - deaths: number; - forfeits: number; - relicsBanked: number; -} - -export type BoardTab = 'fresh' | 'veteran'; - -export interface ChromeView { - showCreator: boolean; - showVault: boolean; - draft: Draft; - creatorHooks: CreatorHooks; - vaultHeroes: Hero[] | null; - vaultLoading: boolean; - onVeteran: (id: number) => void; - boardTab: BoardTab; - chainHeroId: number | null; - canRetry: boolean; - verifyState: VerifyState; - runId: number; - showLeaderboard: boolean; - showStats: boolean; - leaderboardLoading: boolean; - leaderboardRows: LeaderboardRow[] | null; - displayRelics: number; - justVerified: boolean; - playerAddress: string; - run: RunState | null; - progress: Progress; - onchain: OnchainSnapshot | null; - relics: RelicBalances | null; - chainRead: ChainRead; - chainStatus: string; - chainTx: string; - chainRank: number | null; - starting: boolean; - startStage: DescentStage; - descent: DescentOptions | null; - practice: boolean; - onDescendHover: (hovering: boolean) => void; - /** Fired once, synchronously with the seal's strike animation, when a run is verified. */ - onSeal: () => void; -} - -/** The slice of the view each card reads, so a card can be mounted on its own for previews and checks. */ -export type VaultView = Pick; -export type LeaderboardView = Pick; +import type { ChromeView } from './chromeTypes'; +import { isTouchFirst, setBumpText, setText } from './dom'; +import { mountCreator } from './creator'; +import { cloakFilter } from './emblems'; +import { leaderboardCard } from './leaderboardCard'; +import { combatCard, choiceCard } from './promptCards'; +import { mountResult, updateVerification, type ResultSummary, type Verification } from './result'; +import { mountDescent, setDescentStage } from './descent'; +import { delverOutcome } from '../game/delverOutcome'; +import { shouldNudgeRecord } from '../game/recordNudge'; +import { statsCard } from './statsCard'; +import { mountTitle, updateTitle, type TitleInfo } from './title'; +import { vaultCard } from './vaultCard'; const TX_HASH = /^[a-f0-9]{64}$/; @@ -92,49 +29,6 @@ function relicSummary(relics: RelicBalances | null): string { return parts.join(', '); } -function el( - tag: K, - className?: string, - text?: string -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); - if (className) node.className = className; - if (text !== undefined) node.textContent = text; - return node; -} - -function button(label: string, action: string, className = 'btn'): HTMLButtonElement { - const node = el('button', className, label); - node.type = 'button'; - node.dataset.action = action; - return node; -} - -function shortenAddress(address: string): string { - if (address.length < 10) return address; - return `${address.slice(0, 4)}...${address.slice(-4)}`; -} - -function setText(id: string, value: string): void { - const node = document.getElementById(id); - if (node && node.textContent !== value) node.textContent = value; -} - -/** Sets text and, if it changed, replays a small pop so gains are felt. */ -function setBumpText(id: string, value: string): void { - const node = document.getElementById(id); - if (!node || node.textContent === value) return; - const first = node.textContent === ''; - node.textContent = value; - if (first) return; - node.classList.remove('bump'); - void node.offsetWidth; - node.classList.add('bump'); -} - -/** The bar turns red at or below this share of max health. A low state is also spoken, so colour is not the only cue. */ -const LOW_HEALTH_RATIO = 0.34; - function paintHp(hp: number, max: number): void { const ratio = max > 0 ? Math.max(0, Math.min(1, hp / max)) : 0; const fill = document.getElementById('hp-fill'); @@ -217,7 +111,7 @@ function renderOverlay(view: ChromeView): void { const info = titleInfo(view); if (overlay.querySelector('.title-v2')) updateTitle(info); else mountTitle(overlay, info); - if (PANEL_KEYS.some((panel) => previousKey.startsWith(panel))) { + if (!isTouchFirst() && PANEL_KEYS.some((panel) => previousKey.startsWith(panel))) { overlay.querySelector('.menu-item.is-primary')?.focus({ preventScroll: true }); } return; @@ -250,10 +144,8 @@ function overlaySignature(view: ChromeView): string { return `vault:${view.vaultLoading}:${ids ?? ''}`; } if (view.showLeaderboard) { - const rows = view.leaderboardRows - ?.map((row) => `${row.address}:${row.score}:${row.floor}:${row.hpLeft}:${row.actions}`) - .join('|'); - return `board:${view.boardTab}:${view.leaderboardLoading}:${rows ?? ''}:${view.playerAddress}`; + const rows = view.leaderboardRows?.map((row) => `${row.address}:${row.score}:${row.floor}:${row.hpLeft}:${row.actions}`).join('|'); + return `board:${view.boardTab}:${view.leaderboardLoading}:${view.leaderboardFailed}:${rows ?? ''}:${view.playerAddress}`; } if (view.showStats) { const progress = view.progress; @@ -261,7 +153,7 @@ function overlaySignature(view: ChromeView): string { ? `${view.onchain.bestFloor}:${view.onchain.bestScore}:${view.onchain.runs}:${view.onchain.extractions}:${view.onchain.deaths}:${view.onchain.forfeits}:${view.onchain.relicsBanked}` : ''; const vault = view.relics ? `${view.relics.common}:${view.relics.rare}:${view.relics.core}` : ''; - return `stats:${view.chainRead}:${chain}:${vault}:${view.playerAddress}:${progress.runsPlayed}:${progress.extractions}:${progress.deaths}:${progress.bestFloor}:${progress.bestRelics}:${progress.bestScore}:${progress.relicsKept}:${progress.lanternRuns}:${progress.cinderRuns}`; + return `stats:${view.restoreStep}:${view.restoreError}:${view.restoreTarget}:${view.recoveryNote}:${view.chainRead}:${chain}:${vault}:${view.playerAddress}:${progress.runsPlayed}:${progress.extractions}:${progress.deaths}:${progress.bestFloor}:${progress.bestRelics}:${progress.bestScore}:${progress.relicsKept}:${progress.lanternRuns}:${progress.cinderRuns}`; } if (!view.run) return 'title'; if (view.run.mode === 'result') { @@ -280,330 +172,18 @@ function overlaySignature(view: ChromeView): string { function titleInfo(view: ChromeView): TitleInfo { const progress = view.progress; - const local = progress.runsPlayed > 0 - ? `Best floor ${progress.bestFloor} / ${MAX_FLOOR} · Best relics ${progress.bestRelics} · Runs ${progress.runsPlayed}` - : ''; - const chain = view.onchain && view.onchain.runs > 0 - ? `Verified on Stellar: best score ${view.onchain.bestScore}, floor ${view.onchain.bestFloor} / ${MAX_FLOOR}, ${view.onchain.runs} runs` - : ''; + const local = + progress.runsPlayed > 0 + ? `Best floor ${progress.bestFloor} / ${MAX_FLOOR} · Best relics ${progress.bestRelics} · Runs ${progress.runsPlayed}` + : ''; + const chain = + view.onchain && view.onchain.runs > 0 + ? `Verified on Stellar: best score ${view.onchain.bestScore}, floor ${view.onchain.bestFloor} / ${MAX_FLOOR}, ${view.onchain.runs} runs` + : ''; const held = relicSummary(view.relics); return { local, chain, vault: held ? `Relics in your vault: ${held}` : '' }; } -export function leaderboardCard(view: LeaderboardView): HTMLElement { - const card = el('div', 'panel panel-scroll'); - card.append(el('h2', 'panel-title', 'Leaderboard')); - card.append( - el('p', 'panel-sub', 'Best verified runs. The contract replays every run before it is ranked.') - ); - - const tabs = el('div', 'tab-row'); - const fresh = button('Fresh delvers', 'tabFresh', view.boardTab === 'fresh' ? 'btn btn-primary' : 'btn'); - const veteran = button('Veterans', 'tabVeteran', view.boardTab === 'veteran' ? 'btn btn-primary' : 'btn'); - fresh.setAttribute('aria-pressed', view.boardTab === 'fresh' ? 'true' : 'false'); - veteran.setAttribute('aria-pressed', view.boardTab === 'veteran' ? 'true' : 'false'); - tabs.append(fresh, veteran); - card.append(tabs); - - if (view.leaderboardLoading) { - card.append(el('p', 'panel-body', 'Loading records...')); - } else if (!view.leaderboardRows || view.leaderboardRows.length === 0) { - card.append( - el('p', 'panel-body', view.boardTab === 'veteran' ? 'No veteran has descended yet.' : 'No verified runs yet. Be the first.') - ); - } else { - const table = el('ol', 'board'); - view.leaderboardRows.forEach((row, index) => { - const mine = row.address === view.playerAddress; - const fresh = mine && view.justVerified; - const item = el('li', mine ? 'board-row is-you' : 'board-row'); - if (fresh) item.classList.add('is-fresh'); - item.style.animationDelay = `${index * 60}ms`; - const rank = el('span', 'board-rank', `${index + 1}`); - if (fresh) rank.style.animationDelay = `${index * 60 + 180}ms`; - item.append(rank); - const who = el('span', 'board-who'); - who.append(emblemIcon(row.emblem, 'emblem emblem-small')); - who.append(el('span', 'board-name', mine ? `${row.name} (you)` : row.name)); - who.append(el('span', 'board-addr', shortenAddress(row.address))); - item.append(who); - item.append(el('span', 'board-stat', `${row.score}`)); - item.append(el('span', 'board-stat', `${row.floor}/${MAX_FLOOR}`)); - item.append(el('span', 'board-stat', `${row.hpLeft}`)); - item.append(el('span', 'board-stat', `${row.actions}`)); - table.append(item); - }); - const head = el('div', 'board-head'); - head.append(el('span', '', '#')); - head.append(el('span', '', 'Delver')); - head.append(el('span', '', 'Score')); - head.append(el('span', '', 'Floor')); - head.append(el('span', '', 'HP')); - head.append(el('span', '', 'Moves')); - card.append(head, table); - } - - card.append(button('Back', 'cancel', 'btn')); - return card; -} - -export function vaultCard(view: VaultView): HTMLElement { - const wrap = el('div', 'vault-view'); - wrap.append(el('div', 'vault-backdrop')); - - const card = el('div', 'panel panel-scroll panel-wide'); - card.append(el('h2', 'panel-title', 'Your vault')); - card.append( - el( - 'p', - 'panel-sub', - 'Delvers you earned by clearing all three floors. Each is a token on Stellar that you own, and each can descend again as a veteran.' - ) - ); - - if (view.vaultLoading || !view.vaultHeroes) { - card.append(el('p', 'panel-body', view.vaultLoading ? 'Opening the vault...' : 'The vault could not be read.')); - } else if (view.vaultHeroes.length === 0) { - card.append(el('p', 'panel-body', 'Empty for now. Clear all three floors with a new delver to claim one.')); - } else { - const list = el('div', 'vault-list'); - view.vaultHeroes.forEach((hero, index) => { - const item = heroCard(hero, view); - item.style.animationDelay = `${index * 60}ms`; - list.append(item); - }); - card.append(list); - } - - card.append(button('Back', 'cancel', 'btn')); - wrap.append(card); - return wrap; -} - -function heroCard(hero: Hero, view: VaultView): HTMLElement { - const item = el('article', 'vault-card'); - const baseId = HERO_ORDER[hero.base] ?? 'lantern'; - const portrait = el('img', 'vault-portrait'); - portrait.src = `/art/${HEROES[baseId].veteranArt}`; - portrait.alt = ''; - portrait.style.filter = cloakFilter(hero.look.cloak, hero.base); - const copy = el('div', 'vault-copy'); - const title = el('p', 'vault-name'); - title.append(emblemIcon(hero.look.emblem, 'emblem emblem-small'), el('span', '', hero.look.name)); - const kit = `${HEROES[baseId].name} · ${WEAPONS[hero.weapon]?.name ?? '?'} · ${CHARMS[hero.charm]?.name ?? '?'}`; - const icons = el('span', 'vault-icons'); - for (const file of [WEAPONS[hero.weapon]?.icon, CHARMS[hero.charm]?.icon]) { - if (!file) continue; - const icon = el('img', 'vault-icon'); - icon.src = `/art/${file}`; - icon.alt = ''; - icons.append(icon); - } - const rank = Math.min(hero.clears, MAX_RANK); - const pips = el('span', 'vault-pips'); - for (let i = 0; i < MAX_RANK; i += 1) pips.append(el('span', i < rank ? 'vault-pip is-filled' : 'vault-pip')); - const rankLine = el('p', 'vault-meta'); - rankLine.append(pips, document.createTextNode(`Veteran rank ${rank} · ${hero.clears} ${hero.clears === 1 ? 'clear' : 'clears'}`)); - copy.append( - title, - el('p', 'vault-kit', kit), - icons, - rankLine, - el('p', 'vault-meta', `First clear: score ${hero.proof_score}, ${hero.proof_hp_left} HP left, ${hero.proof_actions} moves`) - ); - const go = el('button', 'btn btn-primary', 'Descend as a veteran'); - go.type = 'button'; - go.addEventListener('click', () => view.onVeteran(hero.id)); - item.append(portrait, copy, go); - return item; -} - -function metric(value: string, label: string): HTMLElement { - const node = el('div', 'metric'); - node.append(el('span', 'metric-value', value)); - node.append(el('span', 'metric-label', label)); - return node; -} - -function metricGrid(tiles: HTMLElement[]): HTMLElement { - const grid = el('div', 'metric-grid'); - for (const tile of tiles) grid.append(tile); - return grid; -} - -function statsCard(view: ChromeView): HTMLElement { - const card = el('div', 'panel panel-scroll panel-wide'); - card.append(el('h2', 'panel-title', 'Your record')); - card.append(el('p', 'panel-sub', 'This browser keeps the detail. Stellar keeps the mark anyone can read.')); - - const local = el('section', 'metric-group'); - local.append(el('h3', 'metric-heading', 'This browser')); - const progress = view.progress; - if (progress.runsPlayed === 0) { - local.append(el('p', 'panel-body', 'No finished run in this browser yet.')); - } else { - const outcomes = progress.extractions + progress.deaths; - const delvers = progress.lanternRuns + progress.cinderRuns; - const partial = outcomes < progress.runsPlayed || delvers < progress.runsPlayed; - const hasNewDetail = outcomes + delvers > 0; - local.append( - metricGrid( - hasNewDetail - ? [ - metric(String(progress.runsPlayed), 'Runs'), - metric(String(progress.extractions), 'Extracted'), - metric(String(progress.deaths), 'Lost'), - metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'), - metric(String(progress.bestRelics), 'Best relics'), - metric(String(progress.relicsKept), 'Relics banked'), - metric(String(progress.bestScore), 'Best score'), - metric(String(progress.lanternRuns), 'Lantern'), - metric(String(progress.cinderRuns), 'Cinder'), - ] - : [ - metric(String(progress.runsPlayed), 'Runs'), - metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'), - metric(String(progress.bestRelics), 'Best relics'), - ] - ) - ); - if (partial) { - local.append( - el( - 'p', - 'panel-sub', - 'Wins, losses, score, relics banked, and delver counts include only runs finished after this page shipped. Earlier runs still count toward the total, best floor, and best relics.' - ) - ); - } - } - card.append(local); - - const chain = el('section', 'metric-group'); - chain.append(el('h3', 'metric-heading', 'Stellar testnet')); - if (view.playerAddress) { - chain.append(el('p', 'panel-sub', `You ${shortenAddress(view.playerAddress)}`)); - } - if (view.chainRead === 'loading') { - chain.append(el('p', 'panel-body', 'Reading the Stellar record...')); - } else if (view.chainRead === 'unavailable') { - chain.append(el('p', 'panel-body', 'Stellar could not be reached from this browser.')); - } else if (!view.onchain) { - chain.append(el('p', 'panel-body', 'No run recorded for you on Stellar yet.')); - } else { - chain.append(el('p', 'panel-sub', 'Every number here was derived by the contract replaying your runs.')); - chain.append( - metricGrid([ - metric(String(view.onchain.bestScore), 'Best score'), - metric(`${view.onchain.bestFloor} / ${MAX_FLOOR}`, 'Best floor'), - metric(String(view.onchain.runs), 'Runs'), - metric(String(view.onchain.extractions), 'Extracted'), - metric(String(view.onchain.deaths), 'Lost'), - metric(String(view.onchain.forfeits), 'Abandoned'), - metric(String(view.onchain.relicsBanked), 'Relics banked'), - ]) - ); - } - if (view.relics) { - chain.append( - metricGrid([ - metric(String(view.relics.common), 'Common relics'), - metric(String(view.relics.rare), 'Rare relics'), - metric(String(view.relics.core), 'Core relics'), - ]) - ); - } - card.append(chain); - - const sound = el('section', 'metric-group'); - sound.append(el('h3', 'metric-heading', 'Sound')); - sound.append(volumeSlider('Effects', getFxVolume(), setFxVolume)); - sound.append(volumeSlider('Ambience', getAmbienceVolume(), setAmbienceVolume)); - card.append(sound); - - const actions = el('div', 'result-actions'); - actions.append(button('Replay hints', 'replayHints', 'btn'), button('Back', 'cancel', 'btn')); - card.append(actions); - return card; -} - -function volumeSlider(label: string, value: number, onChange: (value: number) => void): HTMLElement { - const row = el('label', 'volume-row'); - row.append(el('span', 'volume-label', label)); - const input = el('input', 'volume-input'); - input.type = 'range'; - input.min = '0'; - input.max = '1'; - input.step = '0.05'; - input.value = String(value); - input.addEventListener('input', () => onChange(Number(input.value))); - row.append(input); - return row; -} - -function combatCard(run: RunState): HTMLElement { - const enemy = run.activeEnemyIndex !== null ? run.enemies[run.activeEnemyIndex] : undefined; - const name = enemy ? ENEMIES[enemy.kind].name : 'Something'; - const card = el('div', 'prompt prompt-danger'); - card.append(el('p', 'prompt-title', `${name} blocks the way.`)); - if (enemy) card.append(el('p', 'prompt-sub', `${enemy.hp} / ${enemy.maxHp} HP`)); - const actions = el('div', 'prompt-actions'); - actions.append(button('Attack', 'confirm', 'btn btn-primary')); - actions.append(button('Retreat', 'cancel', 'btn')); - card.append(actions); - return card; -} - -const RELIC_KIND_ORDER: readonly RelicKind[] = ['core', 'rare', 'common']; - -function choiceCard(run: RunState, onDescendHover: (hovering: boolean) => void): HTMLElement { - const card = el('div', 'prompt prompt-safe'); - card.append(el('p', 'prompt-title', `Floor ${run.floorNumber} is clear.`)); - - const stake = el('div', 'choice-stake'); - - const hpRow = el('div', 'choice-hp'); - const track = el('span', 'hp-track'); - const fill = el('span', 'hp-fill'); - const ratio = run.player.maxHp > 0 ? Math.max(0, Math.min(1, run.player.hp / run.player.maxHp)) : 0; - fill.style.width = `${(ratio * 100).toFixed(1)}%`; - track.append(fill); - hpRow.append(track, el('span', 'choice-hp-text', `${run.player.hp} / ${run.player.maxHp} HP`)); - stake.append(hpRow); - - const held = RELIC_KIND_ORDER.filter((kind) => run.relicsByKind[kind] > 0); - if (held.length > 0) { - const relicRow = el('div', 'choice-relics'); - for (const kind of held) { - const chip = el('span', 'choice-relic-chip'); - const icon = el('img', 'choice-relic-icon'); - icon.src = `/art/relic-${kind}.webp`; - icon.alt = ''; - chip.append(icon, el('span', 'sr-only', `${kind} relics `), el('span', '', `×${run.relicsByKind[kind]}`)); - relicRow.append(chip); - } - stake.append(relicRow); - } - - stake.append(el('span', 'choice-score', `Score ${run.scoreThisRun}`)); - card.append(stake); - - const nextLayout = buildFloor(run.floorNumber + 1); - const guardians = [...new Set(nextLayout.enemies.map((enemy) => ENEMIES[enemy.kind].name))]; - if (guardians.length > 0) { - card.append(el('p', 'prompt-sub', `Ahead: ${guardians.join(', ')}.`)); - } - card.append(el('p', 'prompt-sub', 'Descending keeps your HP and raises the risk. Extracting banks what you hold.')); - - const actions = el('div', 'prompt-actions'); - const descend = button('Descend', 'confirm', 'btn btn-primary'); - descend.addEventListener('pointerenter', () => onDescendHover(true)); - descend.addEventListener('pointerleave', () => onDescendHover(false)); - actions.append(descend, button('Extract', 'cancel', 'btn')); - card.append(actions); - return card; -} - function resultSummary(view: ChromeView): ResultSummary { const run = view.run as RunState; const won = run.resultKind === 'extracted'; @@ -616,7 +196,7 @@ function resultSummary(view: ChromeView): ResultSummary { score: won ? run.scoreThisRun : 0, hpLeft: run.player.hp, newBest: run.newBest, - bestLine: `Best floor ${view.progress.bestFloor} / ${MAX_FLOOR} · Best relics ${view.progress.bestRelics}`, + bestLine: view.practice ? '' : `Best floor ${view.progress.bestFloor} / ${MAX_FLOOR} · Best relics ${view.progress.bestRelics}`, delver: { name: run.look.name, art: HEROES[heroId].veteranArt, @@ -628,6 +208,7 @@ function resultSummary(view: ChromeView): ResultSummary { function verificationOf(view: ChromeView): Verification { const url = TX_HASH.test(view.chainTx) ? `${STELLAR_CONFIG.explorerUrl}/tx/${view.chainTx}` : null; + const outcome = delverOutcome(view.chainHeroId, view.runWasVeteran, view.chainClears); return { state: view.practice ? 'practice' : view.verifyState, message: view.practice @@ -635,8 +216,15 @@ function verificationOf(view: ChromeView): Verification { : view.chainStatus, rank: view.chainRank, txUrl: url, - heroEarned: view.chainHeroId !== null, + heroEarned: outcome.kind === 'earned', + delverGrew: outcome.kind === 'grew' ? { clears: outcome.clears } : null, canRetry: view.canRetry, + recordNudge: shouldNudgeRecord({ + practice: view.practice, + verified: view.verifyState === 'verified', + won: view.run?.resultKind === 'extracted', + verifiedRuns: view.onchain?.runs ?? null, + }), }; } @@ -654,8 +242,8 @@ export function syncChrome(view: ChromeView): void { if (playing && view.run) { const floorName = ['', 'Reliquary', 'Drowned chapel', 'Obsidian crypt'][view.run.floorNumber] ?? ''; - const practice = view.practice ? ' · Practice' : ''; - setText('floor-label', `Floor ${view.run.floorNumber} / ${MAX_FLOOR} ${floorName}${practice}`); + // A practice run is already labelled in the run status, so the title bar stays short. + setText('floor-label', `Floor ${view.run.floorNumber} / ${MAX_FLOOR} ${floorName}`); setText('hud-name', view.run.look.name); paintHp(view.run.player.hp, view.run.player.maxHp); setBumpText('hud-relics', `Relics ${view.displayRelics}`); diff --git a/client/src/render/chromeTypes.ts b/client/src/render/chromeTypes.ts new file mode 100644 index 0000000..9161549 --- /dev/null +++ b/client/src/render/chromeTypes.ts @@ -0,0 +1,84 @@ +import type { Draft } from '../game/draft'; +import type { Progress } from '../game/progress'; +import type { RunState } from '../game/state'; +import type { Hero } from '../stellar/contract'; +import type { RelicBalances } from '../stellar/relics'; +import type { CreatorHooks } from './creator'; +import type { DescentOptions, DescentStage } from './descent'; +import type { VerifyState } from './result'; + +export interface LeaderboardRow { + address: string; + name: string; + emblem: number; + score: number; + floor: number; + hpLeft: number; + actions: number; +} + +export type ChainRead = 'loading' | 'ready' | 'unavailable'; + +export interface OnchainSnapshot { + bestFloor: number; + bestScore: number; + runs: number; + extractions: number; + deaths: number; + forfeits: number; + relicsBanked: number; +} + +export type BoardTab = 'fresh' | 'veteran'; + +export interface ChromeView { + showCreator: boolean; + showVault: boolean; + draft: Draft; + creatorHooks: CreatorHooks; + vaultHeroes: Hero[] | null; + vaultLoading: boolean; + onVeteran: (id: number) => void; + boardTab: BoardTab; + chainHeroId: number | null; + /** The veteran's clear count after a verified clear, when known. */ + chainClears: number | null; + runWasVeteran: boolean; + canRetry: boolean; + verifyState: VerifyState; + runId: number; + showLeaderboard: boolean; + showStats: boolean; + leaderboardLoading: boolean; + leaderboardRows: LeaderboardRow[] | null; + leaderboardFailed: boolean; + recoveryNote: string; + restoreStep: 'closed' | 'enter' | 'confirm'; + restoreError: string; + restoreTarget: string; + displayRelics: number; + justVerified: boolean; + playerAddress: string; + run: RunState | null; + progress: Progress; + onchain: OnchainSnapshot | null; + relics: RelicBalances | null; + chainRead: ChainRead; + chainStatus: string; + chainTx: string; + chainRank: number | null; + starting: boolean; + startStage: DescentStage; + descent: DescentOptions | null; + practice: boolean; + onDescendHover: (hovering: boolean) => void; + /** Fired once, synchronously with the seal's strike animation, when a run is verified. */ + onSeal: () => void; +} + +/** The slice of the view each card reads, so a card can be mounted on its own for previews and checks. */ +export type VaultView = Pick; +export type LeaderboardView = Pick< + ChromeView, + 'boardTab' | 'leaderboardLoading' | 'leaderboardRows' | 'leaderboardFailed' | 'playerAddress' | 'justVerified' +>; diff --git a/client/src/render/creator.ts b/client/src/render/creator.ts index 59a3f60..1a28d22 100644 --- a/client/src/render/creator.ts +++ b/client/src/render/creator.ts @@ -3,6 +3,7 @@ import { CHARMS, WEAPONS, type Item } from '../game/items'; import { isValidName, NAME_MAX, NAME_MIN, statsFor, EMBLEM_COUNT } from '../game/loadout'; import { loadoutOf, randomName, saveDraft, type Draft } from '../game/draft'; import { clearPercent, difficultyOf, DIFFICULTY_LABEL, isRecommended, RECOMMENDED_KIT, type Difficulty } from '../game/kitDifficulty'; +import { isTouchFirst } from './dom'; import { cloakCount, cloakFilter, cloakName, cloakSwatch, EMBLEM_NAMES, emblemIcon } from './emblems'; export interface CreatorHooks { @@ -44,11 +45,7 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo root.noValidate = true; root.append(el('h2', 'panel-title', 'Forge your delver')); root.append( - el( - 'p', - 'panel-sub', - 'Clear all three floors and this delver is yours to keep, as a token on Stellar. Fall, and it is gone.' - ) + el('p', 'panel-sub', 'Clear all three floors and this delver is yours to keep, as a token on Stellar. Fall, and it is gone.') ); // Preview @@ -166,7 +163,11 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo baseGroup.append(baseList); root.append(baseGroup); - function itemGroup(label: string, items: readonly Item[], pick: (id: number) => void): { group: HTMLElement; buttons: HTMLButtonElement[] } { + function itemGroup( + label: string, + items: readonly Item[], + pick: (id: number) => void + ): { group: HTMLElement; buttons: HTMLButtonElement[] } { const group = el('div', 'field'); group.append(el('span', 'field-label', label)); const list = el('div', 'pick-grid'); @@ -202,7 +203,7 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo }); root.append(weapons.group, charms.group); - const actions = el('div', 'title-actions'); + const actions = el('div', 'title-actions sticky-actions'); const begin = el('button', 'btn btn-primary', 'Begin descent'); begin.type = 'submit'; const back = el('button', 'btn', 'Back'); @@ -220,9 +221,7 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo function refresh(): void { const name = draft.look.name; const valid = isValidName(name); - nameHint.textContent = valid - ? `${name.length} / ${NAME_MAX}` - : `Use ${NAME_MIN} to ${NAME_MAX} letters, numbers and single spaces.`; + nameHint.textContent = valid ? `${name.length} / ${NAME_MAX}` : `Use ${NAME_MIN} to ${NAME_MAX} letters, numbers and single spaces.`; nameHint.classList.toggle('is-error', !valid); nameInput.setAttribute('aria-invalid', valid ? 'false' : 'true'); begin.disabled = !valid; @@ -286,5 +285,6 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo host.append(root); refresh(); - nameInput.focus(); + // A finger cannot use the field before tapping it, and focusing it here would raise the keyboard over the form. + if (!isTouchFirst()) nameInput.focus(); } diff --git a/client/src/render/descent.ts b/client/src/render/descent.ts index 422a4e6..1ebb950 100644 --- a/client/src/render/descent.ts +++ b/client/src/render/descent.ts @@ -6,6 +6,7 @@ * dark. Restraint is the point: nothing here tries to look like a loading bar. */ import { emblemIcon } from './emblems'; +import { waitState } from './descentWait'; import { prefersReducedMotion } from './preferences'; export interface DescentOptions { @@ -17,6 +18,8 @@ export interface DescentOptions { emblem: number; /** One line describing the kit, such as "Cinder · Reaver Hook · Stone Heart". */ kit: string; + /** Starts a practice run right away. When absent the scene never offers the button. */ + onSkip?: () => void; } export type DescentStage = 'account' | 'opening' | 'ready' | 'offline'; @@ -40,6 +43,7 @@ const TIPS: readonly string[] = [ ]; const TIP_MS = 3600; +const WAIT_POLL_MS = 500; const EMBER_COUNT = 34; let setStageRef: ((stage: DescentStage) => void) | null = null; @@ -126,7 +130,16 @@ export function mountDescent(host: HTMLElement, options: DescentOptions): void { const status = el('p', 'descent-status', STAGE_TEXT.account); status.setAttribute('role', 'status'); const tip = el('p', 'descent-tip'); - progress.append(steps, status, tip); + const slowNote = el('p', 'descent-slow', 'Stellar is taking a little longer than usual. Your run will open as soon as it can.'); + slowNote.hidden = true; + const skip = el('button', 'btn descent-skip', 'Play a practice run now'); + skip.type = 'button'; + skip.hidden = true; + skip.addEventListener('click', () => { + skip.disabled = true; + options.onSkip?.(); + }); + progress.append(steps, status, slowNote, skip, tip); root.append(art, light, dust, grain, shade, dossier, progress); host.append(root); @@ -152,7 +165,19 @@ export function mountDescent(host: HTMLElement, options: DescentOptions): void { } paintSteps(0, false); + // After a few calm seconds the scene says so, then offers a way out that never blocks the chain queue. + let stageNow: DescentStage = 'account'; + const shownAt = performance.now(); + function paintWait(): void { + const wait = waitState(performance.now() - shownAt, stageNow); + slowNote.hidden = !wait.slow; + skip.hidden = !options.onSkip || !wait.canSkip; + } + const waitTimer = window.setInterval(paintWait, WAIT_POLL_MS); + setStageRef = (stage) => { + stageNow = stage; + paintWait(); status.textContent = STAGE_TEXT[stage]; if (stage === 'account') paintSteps(0, false); if (stage === 'opening') paintSteps(1, false); @@ -219,6 +244,7 @@ export function mountDescent(host: HTMLElement, options: DescentOptions): void { if (!root.isConnected) { running = false; window.clearInterval(tipTimer); + window.clearInterval(waitTimer); if (setStageRef) setStageRef = null; observer.disconnect(); } diff --git a/client/src/render/descentWait.ts b/client/src/render/descentWait.ts new file mode 100644 index 0000000..7ba9e7f --- /dev/null +++ b/client/src/render/descentWait.ts @@ -0,0 +1,18 @@ +import { SKIP_OFFER_AFTER_MS, SLOW_NOTE_AFTER_MS } from '../constants'; +import type { DescentStage } from './descent'; + +export interface WaitState { + /** Say, calmly, that Stellar is taking a little longer. */ + slow: boolean; + /** Offer the "Play a practice run now" button. */ + canSkip: boolean; +} + +/** What the descent scene shows after waiting `elapsedMs` in `stage`. Only the real waiting stages ever show either. */ +export function waitState(elapsedMs: number, stage: DescentStage): WaitState { + const waiting = stage === 'account' || stage === 'opening'; + return { + slow: waiting && elapsedMs >= SLOW_NOTE_AFTER_MS, + canSkip: waiting && elapsedMs >= SKIP_OFFER_AFTER_MS, + }; +} diff --git a/client/src/render/dom.ts b/client/src/render/dom.ts new file mode 100644 index 0000000..e2ec4aa --- /dev/null +++ b/client/src/render/dom.ts @@ -0,0 +1,42 @@ +/** Small DOM builders shared by the chrome's cards. */ + +export function el(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +export function button(label: string, action: string, className = 'btn'): HTMLButtonElement { + const node = el('button', className, label); + node.type = 'button'; + node.dataset.action = action; + return node; +} + +export function shortenAddress(address: string): string { + if (address.length < 10) return address; + return `${address.slice(0, 4)}...${address.slice(-4)}`; +} + +export function setText(id: string, value: string): void { + const node = document.getElementById(id); + if (node && node.textContent !== value) node.textContent = value; +} + +/** Sets text and, if it changed, replays a small pop so gains are felt. */ +export function setBumpText(id: string, value: string): void { + const node = document.getElementById(id); + if (!node || node.textContent === value) return; + const first = node.textContent === ''; + node.textContent = value; + if (first) return; + node.classList.remove('bump'); + void node.offsetWidth; + node.classList.add('bump'); +} + +/** True when the main pointer is a finger. Focusing a field there raises the keyboard and pulls the page, so nothing is auto focused. */ +export function isTouchFirst(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches; +} diff --git a/client/src/render/effects.ts b/client/src/render/effects.ts index 0233bb2..dd1664b 100644 --- a/client/src/render/effects.ts +++ b/client/src/render/effects.ts @@ -44,10 +44,7 @@ export function getShakeOffset(): [number, number] { return [Math.sin(elapsed * 0.9 + 1.3) * mag, Math.cos(elapsed * 1.1) * mag]; } -export function drawPopups( - ctx: CanvasRenderingContext2D, - toScreen: (x: number, y: number) => { cx: number; cy: number } -): void { +export function drawPopups(ctx: CanvasRenderingContext2D, toScreen: (x: number, y: number) => { cx: number; cy: number }): void { const now = clockNow(); popups = popups.filter((p) => now - p.start < POPUP_LIFETIME_MS); for (const popup of popups) { @@ -125,10 +122,7 @@ export function spawnBurst(x: number, y: number, kind: BurstKind): void { } } -export function drawParticles( - ctx: CanvasRenderingContext2D, - toScreen: (x: number, y: number) => { cx: number; cy: number } -): void { +export function drawParticles(ctx: CanvasRenderingContext2D, toScreen: (x: number, y: number) => { cx: number; cy: number }): void { const now = clockNow(); particles = particles.filter((p) => now - p.start < p.ttl); for (const p of particles) { @@ -156,7 +150,7 @@ export function drawLowHealth(ctx: CanvasRenderingContext2D, ratio: number): voi const h = ctx.canvas.height; const urgency = 1 - ratio / 0.34; // A steady tint instead of a heartbeat when the player asks for reduced motion. - const beat = prefersReducedMotion() ? 1 : Math.pow(Math.max(0, Math.sin(clockNow() / 1000 * (3.2 + urgency * 2))), 6); + const beat = prefersReducedMotion() ? 1 : Math.max(0, Math.sin((clockNow() / 1000) * (3.2 + urgency * 2))) ** 6; const alpha = (0.1 + urgency * 0.28) * (0.45 + 0.55 * beat); const g = ctx.createRadialGradient(w / 2, h / 2, h * 0.32, w / 2, h / 2, h * 0.8); g.addColorStop(0, 'rgba(255, 60, 50, 0)'); @@ -196,7 +190,7 @@ export function drawRelicTravel(ctx: CanvasRenderingContext2D): void { arrived.push(t.kind); return false; } - const eased = 1 - Math.pow(1 - p, 3); + const eased = 1 - (1 - p) ** 3; const x = t.x0 + (t.x1 - t.x0) * eased; const lift = Math.sin(p * Math.PI) * -26; const y = t.y0 + (t.y1 - t.y0) * eased + lift; diff --git a/client/src/render/hints.ts b/client/src/render/hints.ts index 082fd9e..e7f7e66 100644 --- a/client/src/render/hints.ts +++ b/client/src/render/hints.ts @@ -4,6 +4,8 @@ * of the board, never a modal, that steps out of the way the moment the * player answers it, so it never blocks input. */ +import { readJson, writeJson } from '../storage'; + export type HintId = 'move' | 'combat' | 'choice'; const STORAGE_KEY = 'deepmark:hints:v1'; @@ -20,21 +22,14 @@ interface HintRecord { } function load(): HintRecord { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw) as HintRecord; - } catch { - // Private mode or corrupt data: the hint just shows again, which is harmless. - } + const stored = readJson(STORAGE_KEY); + // Missing or corrupt data just shows the hint again, which is harmless. + if (stored && typeof stored === 'object' && 'seen' in stored) return stored as HintRecord; return { seen: {} }; } function save(): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(record)); - } catch { - // Nothing persists, but the hint still behaves correctly for this tab. - } + writeJson(STORAGE_KEY, record); } let record = load(); diff --git a/client/src/render/leaderboardCard.ts b/client/src/render/leaderboardCard.ts new file mode 100644 index 0000000..93c04a6 --- /dev/null +++ b/client/src/render/leaderboardCard.ts @@ -0,0 +1,68 @@ +import { MAX_FLOOR } from '../game/floor'; +import { button, el, shortenAddress } from './dom'; +import { emblemIcon } from './emblems'; +import { loadingBlock } from './loader'; +import type { LeaderboardView } from './chromeTypes'; + +/** Rows enter one after another so the table reads top down. */ +const ROW_STAGGER_MS = 60; +/** The rank number of a freshly verified row lands a beat after its row. */ +const FRESH_RANK_DELAY_MS = 180; + +export function leaderboardCard(view: LeaderboardView): HTMLElement { + const card = el('div', 'panel panel-scroll'); + card.append(el('h2', 'panel-title', 'Leaderboard')); + card.append(el('p', 'panel-sub', 'Best verified runs. The contract replays every run before it is ranked.')); + + const tabs = el('div', 'tab-row'); + const fresh = button('Fresh delvers', 'tabFresh', view.boardTab === 'fresh' ? 'btn btn-primary' : 'btn'); + const veteran = button('Veterans', 'tabVeteran', view.boardTab === 'veteran' ? 'btn btn-primary' : 'btn'); + fresh.setAttribute('aria-pressed', view.boardTab === 'fresh' ? 'true' : 'false'); + veteran.setAttribute('aria-pressed', view.boardTab === 'veteran' ? 'true' : 'false'); + tabs.append(fresh, veteran); + card.append(tabs); + + if (view.leaderboardLoading) { + card.append(loadingBlock('Loading records')); + } else if (view.leaderboardFailed) { + card.append(el('p', 'panel-body', 'The leaderboard could not be read right now. Your connection or Stellar may be having a moment.')); + card.append(button('Try again', 'retryBoard', 'btn btn-primary')); + } else if (!view.leaderboardRows || view.leaderboardRows.length === 0) { + card.append( + el('p', 'panel-body', view.boardTab === 'veteran' ? 'No veteran has descended yet.' : 'No verified runs yet. Be the first.') + ); + } else { + const table = el('ol', 'board'); + view.leaderboardRows.forEach((row, index) => { + const mine = row.address === view.playerAddress; + const fresh = mine && view.justVerified; + const item = el('li', mine ? 'board-row is-you' : 'board-row'); + if (fresh) item.classList.add('is-fresh'); + item.style.animationDelay = `${index * ROW_STAGGER_MS}ms`; + const rank = el('span', 'board-rank', `${index + 1}`); + if (fresh) rank.style.animationDelay = `${index * ROW_STAGGER_MS + FRESH_RANK_DELAY_MS}ms`; + item.append(rank); + const who = el('span', 'board-who'); + who.append(emblemIcon(row.emblem, 'emblem emblem-small')); + who.append(el('span', 'board-name', mine ? `${row.name} (you)` : row.name)); + who.append(el('span', 'board-addr', shortenAddress(row.address))); + item.append(who); + item.append(el('span', 'board-stat', `${row.score}`)); + item.append(el('span', 'board-stat', `${row.floor}/${MAX_FLOOR}`)); + item.append(el('span', 'board-stat', `${row.hpLeft}`)); + item.append(el('span', 'board-stat', `${row.actions}`)); + table.append(item); + }); + const head = el('div', 'board-head'); + head.append(el('span', '', '#')); + head.append(el('span', '', 'Delver')); + head.append(el('span', '', 'Score')); + head.append(el('span', '', 'Floor')); + head.append(el('span', '', 'HP')); + head.append(el('span', '', 'Moves')); + card.append(head, table); + } + + card.append(button('Back', 'cancel', 'btn')); + return card; +} diff --git a/client/src/render/loader.ts b/client/src/render/loader.ts new file mode 100644 index 0000000..e8e273a --- /dev/null +++ b/client/src/render/loader.ts @@ -0,0 +1,37 @@ +import '../loader.css'; +import { sigilIcon } from './sigil'; + +const DEFAULT_ROWS = 3; + +/** + * The shared waiting state for anything that reads the chain: the Deepmark + * sigil breathing above a few skeleton rows. It is announced once as a status + * and marked busy, and the decorative parts are hidden from assistive tech. + */ +export function loadingBlock(message: string, rows = DEFAULT_ROWS): HTMLElement { + const block = document.createElement('div'); + block.className = 'loader'; + block.setAttribute('role', 'status'); + block.setAttribute('aria-busy', 'true'); + + const mark = document.createElement('span'); + mark.className = 'loader-mark'; + mark.append(sigilIcon()); + mark.setAttribute('aria-hidden', 'true'); + + const text = document.createElement('p'); + text.className = 'loader-text'; + text.textContent = message; + + const lines = document.createElement('div'); + lines.className = 'loader-rows'; + lines.setAttribute('aria-hidden', 'true'); + for (let i = 0; i < rows; i += 1) { + const line = document.createElement('span'); + line.className = 'loader-row'; + lines.append(line); + } + + block.append(mark, text, lines); + return block; +} diff --git a/client/src/render/motion.ts b/client/src/render/motion.ts index cc6822a..33023d9 100644 --- a/client/src/render/motion.ts +++ b/client/src/render/motion.ts @@ -38,7 +38,11 @@ export function playAttack(id: string, x: number, y: number): void { }); } -export function sampleActor(id: string, x: number, y: number): { +export function sampleActor( + id: string, + x: number, + y: number +): { x: number; y: number; clip: Clip; diff --git a/client/src/render/promptCards.ts b/client/src/render/promptCards.ts new file mode 100644 index 0000000..8dc41ed --- /dev/null +++ b/client/src/render/promptCards.ts @@ -0,0 +1,67 @@ +import { ENEMIES } from '../game/enemies'; +import { buildFloor, type RelicKind } from '../game/floor'; +import type { RunState } from '../game/state'; +import { button, el } from './dom'; + +export function combatCard(run: RunState): HTMLElement { + const enemy = run.activeEnemyIndex !== null ? run.enemies[run.activeEnemyIndex] : undefined; + const name = enemy ? ENEMIES[enemy.kind].name : 'Something'; + const card = el('div', 'prompt prompt-danger'); + card.append(el('p', 'prompt-title', `${name} blocks the way.`)); + if (enemy) card.append(el('p', 'prompt-sub', `${enemy.hp} / ${enemy.maxHp} HP`)); + const actions = el('div', 'prompt-actions'); + actions.append(button('Attack', 'confirm', 'btn btn-primary')); + actions.append(button('Retreat', 'cancel', 'btn')); + card.append(actions); + return card; +} + +const RELIC_KIND_ORDER: readonly RelicKind[] = ['core', 'rare', 'common']; + +export function choiceCard(run: RunState, onDescendHover: (hovering: boolean) => void): HTMLElement { + const card = el('div', 'prompt prompt-safe'); + card.append(el('p', 'prompt-title', `Floor ${run.floorNumber} is clear.`)); + + const stake = el('div', 'choice-stake'); + + const hpRow = el('div', 'choice-hp'); + const track = el('span', 'hp-track'); + const fill = el('span', 'hp-fill'); + const ratio = run.player.maxHp > 0 ? Math.max(0, Math.min(1, run.player.hp / run.player.maxHp)) : 0; + fill.style.width = `${(ratio * 100).toFixed(1)}%`; + track.append(fill); + hpRow.append(track, el('span', 'choice-hp-text', `${run.player.hp} / ${run.player.maxHp} HP`)); + stake.append(hpRow); + + const held = RELIC_KIND_ORDER.filter((kind) => run.relicsByKind[kind] > 0); + if (held.length > 0) { + const relicRow = el('div', 'choice-relics'); + for (const kind of held) { + const chip = el('span', 'choice-relic-chip'); + const icon = el('img', 'choice-relic-icon'); + icon.src = `/art/relic-${kind}.webp`; + icon.alt = ''; + chip.append(icon, el('span', 'sr-only', `${kind} relics `), el('span', '', `×${run.relicsByKind[kind]}`)); + relicRow.append(chip); + } + stake.append(relicRow); + } + + stake.append(el('span', 'choice-score', `Score ${run.scoreThisRun}`)); + card.append(stake); + + const nextLayout = buildFloor(run.floorNumber + 1); + const guardians = [...new Set(nextLayout.enemies.map((enemy) => ENEMIES[enemy.kind].name))]; + if (guardians.length > 0) { + card.append(el('p', 'prompt-sub', `Ahead: ${guardians.join(', ')}.`)); + } + card.append(el('p', 'prompt-sub', 'Descending keeps your HP and raises the risk. Extracting banks what you hold.')); + + const actions = el('div', 'prompt-actions'); + const descend = button('Descend', 'confirm', 'btn btn-primary'); + descend.addEventListener('pointerenter', () => onDescendHover(true)); + descend.addEventListener('pointerleave', () => onDescendHover(false)); + actions.append(descend, button('Extract', 'cancel', 'btn')); + card.append(actions); + return card; +} diff --git a/client/src/render/result.ts b/client/src/render/result.ts index 46755dc..c8426b0 100644 --- a/client/src/render/result.ts +++ b/client/src/render/result.ts @@ -8,8 +8,10 @@ * Like the creator, this component owns its DOM. The overlay renderer mounts it * once per run and only calls `updateVerification` afterwards. */ +import { DELVER_EARNED_KICKER, delverGrewKicker } from '../game/delverOutcome'; import { emblemIcon } from './emblems'; import { prefersReducedMotion } from './preferences'; +import { sigilIcon } from './sigil'; export interface ResultSummary { won: boolean; @@ -31,8 +33,13 @@ export interface Verification { message: string; rank: number | null; txUrl: string | null; + /** A fresh delver was minted by this run (its first full clear). */ heroEarned: boolean; + /** A veteran cleared again: no new delver, but its clear count rose. Null clears means the count is not known yet. */ + delverGrew?: { clears: number | null } | null; canRetry: boolean; + /** A fresh account's first verified win: gently point at Your record so the delver is never lost. */ + recordNudge?: boolean; } const STORY: Record<'pending' | 'waiting', string> = { @@ -58,7 +65,7 @@ function countUp(node: HTMLElement, to: number, suffix = '', delay = 0): void { const start = performance.now(); const step = (now: number): void => { const t = Math.min(1, (now - start) / duration); - const eased = 1 - Math.pow(1 - t, 3); + const eased = 1 - (1 - t) ** 3; node.textContent = `${Math.round(to * eased)}${suffix}`; if (t < 1) requestAnimationFrame(step); }; @@ -75,37 +82,6 @@ function stat(label: string, value: number, suffix: string, delay: number): HTML return tile; } -const SVG_NS = 'http://www.w3.org/2000/svg'; - -function path(d: string, className: string): SVGPathElement { - const node = document.createElementNS(SVG_NS, 'path'); - node.setAttribute('d', d); - node.setAttribute('class', className); - return node; -} - -/** - * The Deepmark sigil: the carved triangle-and-eye mark from the stone logo - * (`/art/logo-lockup.webp`), redrawn as an inline vector so the seal is the - * game's own mark instead of a generic check-in-a-ring. - */ -function sigilIcon(): SVGSVGElement { - const svg = document.createElementNS(SVG_NS, 'svg'); - svg.setAttribute('viewBox', '0 0 64 64'); - svg.setAttribute('class', 'seal-icon'); - svg.setAttribute('aria-hidden', 'true'); - const outer = path('M32 9 L53 51 L11 51 Z', 'seal-outer'); - const inner = path('M23 19 L41 19 L32 31 Z', 'seal-inner'); - const eye = path('M21 41 Q32 34 43 41 Q32 48 21 41 Z', 'seal-eye'); - const pupil = document.createElementNS(SVG_NS, 'circle'); - pupil.setAttribute('cx', '32'); - pupil.setAttribute('cy', '41'); - pupil.setAttribute('r', '2.4'); - pupil.setAttribute('class', 'seal-pupil'); - svg.append(outer, inner, eye, pupil); - return svg; -} - const STRIKE_KEYFRAMES: Keyframe[] = [ { opacity: 0, transform: 'scale(1.9) rotate(-14deg) translateY(-6px)' }, { opacity: 1, transform: 'scale(0.92) rotate(-4deg) translateY(0)', offset: 0.55 }, @@ -161,7 +137,7 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?: ); card.append(stats); if (summary.newBest) card.append(el('p', 'new-best', 'New best')); - card.append(el('p', 'stats', summary.bestLine)); + if (summary.bestLine) card.append(el('p', 'stats', summary.bestLine)); // The replay ribbon: one pip per floor, a sweeping light while the contract reads the run. const ribbon = el('div', 'ribbon is-pending'); @@ -209,7 +185,8 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?: crest.append(emblemIcon(summary.delver.emblem, 'emblem')); medallion.append(portrait, crest); const heroCopy = el('div', 'hero-copy-block'); - heroCopy.append(el('p', 'hero-kicker', 'A new delver is yours'), el('p', 'hero-earned-name', summary.delver.name)); + const kicker = el('p', 'hero-kicker', DELVER_EARNED_KICKER); + heroCopy.append(kicker, el('p', 'hero-earned-name', summary.delver.name)); const vault = el('button', 'btn btn-primary', 'Open the vault'); vault.type = 'button'; vault.dataset.action = 'vault'; @@ -217,6 +194,14 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?: hero.append(medallion, heroCopy); card.append(hero); + const nudge = el('p', 'record-nudge', 'Your delver lives in this browser, so keep your record safe. '); + nudge.hidden = true; + const nudgeLink = el('button', 'quiet-link', 'Open Your record'); + nudgeLink.type = 'button'; + nudgeLink.dataset.action = 'stats'; + nudge.append(nudgeLink); + card.append(nudge); + const share = el('div', 'share-actions'); share.hidden = true; const shareSave = el('button', 'btn', 'Save image'); @@ -231,15 +216,18 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?: share.append(shareSave, shareCopy, shareChallenge); card.append(share); - const actions = el('div', 'result-actions'); + const actions = el('div', 'result-actions sticky-actions'); const retry = el('button', 'btn', 'Try again'); retry.type = 'button'; retry.dataset.action = 'retryVerify'; retry.hidden = true; + const again = el('button', 'btn', 'Descend again'); + again.type = 'button'; + again.dataset.action = 'again'; const cont = el('button', 'btn btn-primary', 'Title screen'); cont.type = 'button'; cont.dataset.action = 'confirm'; - actions.append(retry, cont); + actions.append(retry, again, cont); card.append(actions); host.append(card); @@ -250,7 +238,7 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?: let last = ''; let sealed = false; applyVerification = (v) => { - const key = `${v.state}|${v.message}|${v.rank}|${v.txUrl}|${v.heroEarned}|${v.canRetry}`; + const key = `${v.state}|${v.message}|${v.rank}|${v.txUrl}|${v.heroEarned}|${v.delverGrew?.clears ?? 'x'}|${v.delverGrew ? 1 : 0}|${v.canRetry}|${v.recordNudge ? 1 : 0}`; if (key === last) return; last = key; @@ -258,6 +246,7 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?: ribbon.classList.toggle('is-failed', v.state === 'failed'); ribbon.classList.toggle('is-practice', v.state === 'practice'); retry.hidden = !v.canRetry; + nudge.hidden = !v.recordNudge; if (v.state === 'pending' || v.state === 'waiting') { story.textContent = STORY[v.state]; @@ -281,7 +270,8 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?: tx.hidden = false; tx.href = v.txUrl; } - if (v.heroEarned) { + if (v.heroEarned || v.delverGrew) { + kicker.textContent = v.delverGrew ? delverGrewKicker(v.delverGrew.clears) : DELVER_EARNED_KICKER; hero.hidden = false; hero.classList.add('is-revealed'); } diff --git a/client/src/render/sigil.ts b/client/src/render/sigil.ts new file mode 100644 index 0000000..554adf4 --- /dev/null +++ b/client/src/render/sigil.ts @@ -0,0 +1,30 @@ +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function path(d: string, className: string): SVGPathElement { + const node = document.createElementNS(SVG_NS, 'path'); + node.setAttribute('d', d); + node.setAttribute('class', className); + return node; +} + +/** + * The Deepmark sigil: the carved triangle-and-eye mark from the stone logo + * (`/art/logo-lockup.webp`), redrawn as an inline vector so the seal is the + * game's own mark instead of a generic check-in-a-ring. + */ +export function sigilIcon(): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 64 64'); + svg.setAttribute('class', 'seal-icon'); + svg.setAttribute('aria-hidden', 'true'); + const outer = path('M32 9 L53 51 L11 51 Z', 'seal-outer'); + const inner = path('M23 19 L41 19 L32 31 Z', 'seal-inner'); + const eye = path('M21 41 Q32 34 43 41 Q32 48 21 41 Z', 'seal-eye'); + const pupil = document.createElementNS(SVG_NS, 'circle'); + pupil.setAttribute('cx', '32'); + pupil.setAttribute('cy', '41'); + pupil.setAttribute('r', '2.4'); + pupil.setAttribute('class', 'seal-pupil'); + svg.append(outer, inner, eye, pupil); + return svg; +} diff --git a/client/src/render/statsCard.ts b/client/src/render/statsCard.ts new file mode 100644 index 0000000..bbd23fa --- /dev/null +++ b/client/src/render/statsCard.ts @@ -0,0 +1,212 @@ +import { MAX_FLOOR } from '../game/floor'; +import { getAmbienceVolume, getFxVolume, setAmbienceVolume, setFxVolume } from './audio'; +import { emitAction } from '../input/keyboard'; +import { RESTORE_FIELD_ID } from '../constants'; +import { button, el, shortenAddress } from './dom'; +import { loadingBlock } from './loader'; +import type { ChromeView } from './chromeTypes'; + +/** Volume sliders move in 5 percent steps. */ +const VOLUME_STEP = '0.05'; + +function metric(value: string, label: string): HTMLElement { + const node = el('div', 'metric'); + node.append(el('span', 'metric-value', value)); + node.append(el('span', 'metric-label', label)); + return node; +} + +function metricGrid(tiles: HTMLElement[]): HTMLElement { + const grid = el('div', 'metric-grid'); + for (const tile of tiles) grid.append(tile); + return grid; +} + +/** Step one of restoring: an in-page field. The pasted text is masked and never echoed back. */ +function restoreForm(view: ChromeView): HTMLElement { + const form = el('form', 'field'); + form.noValidate = true; + const label = el('label', 'field-label', 'Paste your recovery code'); + label.htmlFor = RESTORE_FIELD_ID; + const input = el('input', 'input'); + input.id = RESTORE_FIELD_ID; + input.type = 'password'; + input.autocomplete = 'off'; + input.spellcheck = false; + input.setAttribute('autocapitalize', 'off'); + if (view.restoreError) { + input.setAttribute('aria-invalid', 'true'); + input.setAttribute('aria-describedby', 'restore-hint'); + } + const hint = el( + 'p', + view.restoreError ? 'field-hint is-error' : 'field-hint', + view.restoreError || 'Nothing changes until you confirm on the next step.' + ); + hint.id = 'restore-hint'; + hint.setAttribute('role', view.restoreError ? 'alert' : 'note'); + form.addEventListener('submit', (event) => { + event.preventDefault(); + emitAction('restoreCheck'); + }); + const actions = el('div', 'result-actions'); + const next = button('Continue', 'restoreCheck', 'btn'); + actions.append(next, button('Cancel', 'restoreCancel', 'btn')); + form.append(label, input, hint, actions); + setTimeout(() => input.focus({ preventScroll: true }), 0); + return form; +} + +/** Step two: replacing the account is explicit, and the current code can be copied first. */ +function restoreConfirm(view: ChromeView): HTMLElement { + const box = el('div', 'field'); + const who = view.playerAddress ? ` (${shortenAddress(view.playerAddress)})` : ''; + box.append( + el( + 'p', + 'panel-body', + `This browser will switch to the account ${view.restoreTarget}. Your current account${who} will be replaced, and you can only get its record and relics back with its own recovery code.` + ) + ); + if (view.recoveryNote) box.append(el('p', 'field-hint', view.recoveryNote)); + if (view.restoreError) box.append(el('p', 'field-hint is-error', view.restoreError)); + const actions = el('div', 'result-actions'); + actions.append( + button('Copy current recovery code first', 'copyRecovery', 'btn'), + button('Replace this account', 'restoreConfirm', 'btn'), + button('Keep my current account', 'restoreCancel', 'btn') + ); + box.append(actions); + return box; +} + +export function statsCard(view: ChromeView): HTMLElement { + const card = el('div', 'panel panel-scroll panel-wide'); + card.append(el('h2', 'panel-title', 'Your record')); + card.append(el('p', 'panel-sub', 'This browser keeps the detail. Stellar keeps the mark anyone can read.')); + + const local = el('section', 'metric-group'); + local.append(el('h3', 'metric-heading', 'This browser')); + const progress = view.progress; + if (progress.runsPlayed === 0) { + local.append(el('p', 'panel-body', 'No finished run in this browser yet.')); + } else { + const outcomes = progress.extractions + progress.deaths; + const delvers = progress.lanternRuns + progress.cinderRuns; + const partial = outcomes < progress.runsPlayed || delvers < progress.runsPlayed; + const hasNewDetail = outcomes + delvers > 0; + local.append( + metricGrid( + hasNewDetail + ? [ + metric(String(progress.runsPlayed), 'Runs'), + metric(String(progress.extractions), 'Extracted'), + metric(String(progress.deaths), 'Lost'), + metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'), + metric(String(progress.bestRelics), 'Best relics'), + metric(String(progress.relicsKept), 'Relics banked'), + metric(String(progress.bestScore), 'Best score'), + metric(String(progress.lanternRuns), 'Lantern'), + metric(String(progress.cinderRuns), 'Cinder'), + ] + : [ + metric(String(progress.runsPlayed), 'Runs'), + metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'), + metric(String(progress.bestRelics), 'Best relics'), + ] + ) + ); + if (partial) { + local.append( + el( + 'p', + 'panel-sub', + 'Wins, losses, score, relics banked, and delver counts include only runs finished after this page shipped. Earlier runs still count toward the total, best floor, and best relics.' + ) + ); + } + } + card.append(local); + + const chain = el('section', 'metric-group'); + chain.append(el('h3', 'metric-heading', 'Stellar testnet')); + if (view.playerAddress) { + chain.append(el('p', 'panel-sub', `You ${shortenAddress(view.playerAddress)}`)); + } + if (view.chainRead === 'loading') { + chain.append(loadingBlock('Reading the Stellar record', 2)); + } else if (view.chainRead === 'unavailable') { + chain.append(el('p', 'panel-body', 'Stellar could not be reached from this browser.')); + } else if (!view.onchain) { + chain.append(el('p', 'panel-body', 'No run recorded for you on Stellar yet.')); + } else { + chain.append(el('p', 'panel-sub', 'Every number here was derived by the contract replaying your runs.')); + chain.append( + metricGrid([ + metric(String(view.onchain.bestScore), 'Best score'), + metric(`${view.onchain.bestFloor} / ${MAX_FLOOR}`, 'Best floor'), + metric(String(view.onchain.runs), 'Runs'), + metric(String(view.onchain.extractions), 'Extracted'), + metric(String(view.onchain.deaths), 'Lost'), + metric(String(view.onchain.forfeits), 'Abandoned'), + metric(String(view.onchain.relicsBanked), 'Relics banked'), + ]) + ); + } + if (view.relics) { + chain.append( + metricGrid([ + metric(String(view.relics.common), 'Common relics'), + metric(String(view.relics.rare), 'Rare relics'), + metric(String(view.relics.core), 'Core relics'), + ]) + ); + } + card.append(chain); + + const account = el('section', 'metric-group'); + account.append(el('h3', 'metric-heading', 'Keep your record')); + account.append( + el( + 'p', + 'panel-sub', + 'Your record lives in this browser. If you clear its site data or switch browsers, you lose access to it. Copy your recovery code and keep it somewhere private to bring it back.' + ) + ); + if (view.recoveryNote) account.append(el('p', 'panel-body', view.recoveryNote)); + if (view.restoreStep === 'enter') { + account.append(restoreForm(view)); + } else if (view.restoreStep === 'confirm') { + account.append(restoreConfirm(view)); + } else { + const recovery = el('div', 'result-actions'); + recovery.append(button('Copy recovery code', 'copyRecovery', 'btn'), button('Restore from a code', 'restoreRecovery', 'btn')); + account.append(recovery); + } + card.append(account); + + const sound = el('section', 'metric-group'); + sound.append(el('h3', 'metric-heading', 'Sound')); + sound.append(volumeSlider('Effects', getFxVolume(), setFxVolume)); + sound.append(volumeSlider('Ambience', getAmbienceVolume(), setAmbienceVolume)); + card.append(sound); + + const actions = el('div', 'result-actions sticky-actions'); + actions.append(button('Replay hints', 'replayHints', 'btn'), button('Back', 'cancel', 'btn')); + card.append(actions); + return card; +} + +function volumeSlider(label: string, value: number, onChange: (value: number) => void): HTMLElement { + const row = el('label', 'volume-row'); + row.append(el('span', 'volume-label', label)); + const input = el('input', 'volume-input'); + input.type = 'range'; + input.min = '0'; + input.max = '1'; + input.step = VOLUME_STEP; + input.value = String(value); + input.addEventListener('input', () => onChange(Number(input.value))); + row.append(input); + return row; +} diff --git a/client/src/render/title.ts b/client/src/render/title.ts index 47295db..e7808b2 100644 --- a/client/src/render/title.ts +++ b/client/src/render/title.ts @@ -82,7 +82,11 @@ export function mountTitle(host: HTMLElement, info: TitleInfo): void { const hint = el('p', 'title-hint', 'Enter forges a delver. V the vault. L the leaderboard. M your record. N sound.'); const players = el('a', 'quiet-link', 'All players'); players.href = '/stats'; - content.append(logo, tagline, menu, infoLocal, infoChain, infoVault, hint, players); + const docs = el('a', 'quiet-link', 'Docs'); + docs.href = '/docs'; + const links = el('div', 'quiet-links'); + links.append(players, docs); + content.append(logo, tagline, menu, infoLocal, infoChain, infoVault, hint, links); root.append(stage, content); host.append(root); diff --git a/client/src/render/vaultCard.ts b/client/src/render/vaultCard.ts new file mode 100644 index 0000000..60e53ea --- /dev/null +++ b/client/src/render/vaultCard.ts @@ -0,0 +1,87 @@ +import { HEROES, HERO_ORDER } from '../game/heroes'; +import { CHARMS, WEAPONS } from '../game/items'; +import { MAX_RANK } from '../game/loadout'; +import type { Hero } from '../stellar/contract'; +import { el, button } from './dom'; +import { cloakFilter, emblemIcon } from './emblems'; +import { loadingBlock } from './loader'; +import type { VaultView } from './chromeTypes'; + +/** Vault cards enter one after another so the list reads top down. */ +const CARD_STAGGER_MS = 60; + +export function vaultCard(view: VaultView): HTMLElement { + const wrap = el('div', 'vault-view'); + wrap.append(el('div', 'vault-backdrop')); + + const card = el('div', 'panel panel-scroll panel-wide'); + card.append(el('h2', 'panel-title', 'Your vault')); + card.append( + el( + 'p', + 'panel-sub', + 'Delvers you earned by clearing all three floors. Each is a token on Stellar that you own, and each can descend again as a veteran.' + ) + ); + + if (view.vaultLoading || !view.vaultHeroes) { + if (view.vaultLoading) { + card.append(loadingBlock('Opening the vault', 2)); + } else { + card.append(el('p', 'panel-body', 'The vault could not be read right now. Your connection or Stellar may be having a moment.')); + card.append(button('Try again', 'retryVault', 'btn btn-primary')); + } + } else if (view.vaultHeroes.length === 0) { + card.append(el('p', 'panel-body', 'Empty for now. Clear all three floors with a new delver to claim one.')); + } else { + const list = el('div', 'vault-list'); + view.vaultHeroes.forEach((hero, index) => { + const item = heroCard(hero, view); + item.style.animationDelay = `${index * CARD_STAGGER_MS}ms`; + list.append(item); + }); + card.append(list); + } + + card.append(button('Back', 'cancel', 'btn')); + wrap.append(card); + return wrap; +} + +function heroCard(hero: Hero, view: VaultView): HTMLElement { + const item = el('article', 'vault-card'); + const baseId = HERO_ORDER[hero.base] ?? 'lantern'; + const portrait = el('img', 'vault-portrait'); + portrait.src = `/art/${HEROES[baseId].veteranArt}`; + portrait.alt = ''; + portrait.style.filter = cloakFilter(hero.look.cloak, hero.base); + const copy = el('div', 'vault-copy'); + const title = el('p', 'vault-name'); + title.append(emblemIcon(hero.look.emblem, 'emblem emblem-small'), el('span', '', hero.look.name)); + const kit = `${HEROES[baseId].name} · ${WEAPONS[hero.weapon]?.name ?? '?'} · ${CHARMS[hero.charm]?.name ?? '?'}`; + const icons = el('span', 'vault-icons'); + for (const file of [WEAPONS[hero.weapon]?.icon, CHARMS[hero.charm]?.icon]) { + if (!file) continue; + const icon = el('img', 'vault-icon'); + icon.src = `/art/${file}`; + icon.alt = ''; + icons.append(icon); + } + const rank = Math.min(hero.clears, MAX_RANK); + const pips = el('span', 'vault-pips'); + for (let i = 0; i < MAX_RANK; i += 1) pips.append(el('span', i < rank ? 'vault-pip is-filled' : 'vault-pip')); + const rankLine = el('p', 'vault-meta'); + rankLine.append(pips, document.createTextNode(`Veteran rank ${rank} · ${hero.clears} ${hero.clears === 1 ? 'clear' : 'clears'}`)); + copy.append( + title, + el('p', 'vault-kit', kit), + icons, + rankLine, + el('p', 'vault-meta', `First clear: score ${hero.proof_score}, ${hero.proof_hp_left} HP left, ${hero.proof_actions} moves`) + ); + const go = el('button', 'btn btn-primary', 'Descend as a veteran'); + go.type = 'button'; + go.addEventListener('click', () => view.onVeteran(hero.id)); + item.append(portrait, copy, go); + return item; +} diff --git a/client/src/stats/dom.ts b/client/src/stats/dom.ts new file mode 100644 index 0000000..ce74fa6 --- /dev/null +++ b/client/src/stats/dom.ts @@ -0,0 +1,6 @@ +export function el(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} diff --git a/client/src/stats/render.ts b/client/src/stats/render.ts index c20126a..f295552 100644 --- a/client/src/stats/render.ts +++ b/client/src/stats/render.ts @@ -1,4 +1,6 @@ +import { loadingBlock } from '../render/loader'; import { STELLAR_CONFIG } from '../stellar/config'; +import { el } from './dom'; import { MAX_TRACKED_FLOOR, PLAYER_CAP, type DayCount, type PlayerMark, type RecordedRun, type TractionSummary } from './summary'; const WHEN = new Intl.DateTimeFormat('en-GB', { @@ -16,17 +18,7 @@ const DAY = new Intl.DateTimeFormat('en-GB', { day: 'numeric', }); -function el( - tag: K, - className?: string, - text?: string -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); - if (className) node.className = className; - if (text !== undefined) node.textContent = text; - return node; -} - +const LOADING_ROWS = 4; const STELLAR_VIEW = 'https://stellarview.acachete.xyz/en/testnet'; function viewAccount(address: string): string { @@ -138,17 +130,28 @@ function runCard(run: RecordedRun): HTMLElement { } export function renderLoading(root: HTMLElement): void { - root.replaceChildren(shell(el('p', 'lede', 'Reading recorded runs from Stellar testnet...'))); + root.replaceChildren(shell(loadingBlock('Reading recorded runs from Stellar testnet', LOADING_ROWS))); } export function renderError(root: HTMLElement, onRetry: () => void): void { - const body = el('div', 'stack'); - body.append(el('p', 'lede', 'Stellar testnet could not be read from this browser.')); + const body = el('div', 'notice'); + body.setAttribute('role', 'alert'); + body.append(el('h2', 'section-title', 'Could not read the record')); + body.append( + el( + 'p', + 'lede', + 'Stellar testnet could not be read from this browser. Your connection or the network may be having a moment. Nothing was lost.' + ) + ); + const actions = el('div', 'player-actions'); const retry = el('button', 'btn btn-primary', 'Try again'); retry.type = 'button'; retry.addEventListener('click', onRetry); - body.append(retry); + actions.append(retry); + body.append(actions); root.replaceChildren(shell(body)); + retry.focus(); } export function renderSummary(root: HTMLElement, summary: TractionSummary): void { @@ -214,7 +217,9 @@ export function renderSummary(root: HTMLElement, summary: TractionSummary): void const recent = el('section', 'block'); recent.append(el('h2', 'section-title', 'Latest recorded runs')); if (summary.recentRunCount > summary.recentRuns.length) { - recent.append(el('p', 'note', `Showing the latest ${summary.recentRuns.length} of ${summary.recentRunCount} still in Stellar's public history.`)); + recent.append( + el('p', 'note', `Showing the latest ${summary.recentRuns.length} of ${summary.recentRunCount} still in Stellar's public history.`) + ); } const list = el('div', 'player-list'); for (const run of summary.recentRuns) list.append(runCard(run)); @@ -229,10 +234,26 @@ export function renderSummary(root: HTMLElement, summary: TractionSummary): void '. The contract replays each run from its action log and derives the floor, score and relics itself, so a result cannot be claimed without playing it. A death banks no relics.' ); body.append(fine); + const leave = el('div', 'player-actions'); + leave.append(backLink(), docsLink()); + body.append(leave); root.replaceChildren(shell(body)); } +/** The way home, always visible: a real button-sized link, not just the small wordmark. */ +function backLink(): HTMLAnchorElement { + const link = el('a', 'btn btn-back', 'Back to the game'); + link.href = '/'; + return link; +} + +function docsLink(): HTMLAnchorElement { + const link = el('a', 'btn', 'Docs'); + link.href = '/docs/'; + return link; +} + function shell(content: HTMLElement): HTMLElement { const page = el('div', 'page'); const header = el('header', 'page-header'); @@ -241,7 +262,12 @@ function shell(content: HTMLElement): HTMLElement { header.append(brand); header.append(el('p', 'eyebrow', 'Stellar testnet')); page.append(header); + const nav = el('nav', 'page-nav'); + nav.setAttribute('aria-label', 'Deepmark'); + nav.append(backLink(), docsLink()); + page.append(nav); const main = el('main'); + main.setAttribute('aria-busy', content.getAttribute('role') === 'status' ? 'true' : 'false'); main.append(el('h1', 'page-title', 'Recorded play')); main.append(content); page.append(main); diff --git a/client/src/stats/stats.css b/client/src/stats/stats.css index 72192bd..38fdff9 100644 --- a/client/src/stats/stats.css +++ b/client/src/stats/stats.css @@ -218,3 +218,37 @@ button, input { font: inherit; } @media (prefers-reduced-motion: reduce) { .bar-fill { transition: none; } } + +.page-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 4px 0 12px; +} + +.page-nav .btn, +.player-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.btn-back { + background: var(--cyan-deep); + border-color: rgba(126, 240, 224, 0.45); + color: var(--cyan); +} + +.notice { + margin-top: 16px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + border: 1px solid rgba(255, 107, 90, 0.4); + border-radius: 16px; + background: var(--bg-raise); +} + +main > .loader { margin-top: 16px; } diff --git a/client/src/stats/summary.ts b/client/src/stats/summary.ts index 9c76a17..a9a402a 100644 --- a/client/src/stats/summary.ts +++ b/client/src/stats/summary.ts @@ -127,9 +127,7 @@ export function summarize( const day = run.at.slice(0, 10); byDay.set(day, (byDay.get(day) ?? 0) + 1); } - const days = eventWindow - ? utcDays(eventWindow.from, eventWindow.to).map((day) => ({ day, runs: byDay.get(day) ?? 0 })) - : []; + const days = eventWindow ? utcDays(eventWindow.from, eventWindow.to).map((day) => ({ day, runs: byDay.get(day) ?? 0 })) : []; return { players: sorted, playerCount: sorted.length, diff --git a/client/src/stellar/account.ts b/client/src/stellar/account.ts index 6a1dd69..c74c463 100644 --- a/client/src/stellar/account.ts +++ b/client/src/stellar/account.ts @@ -1,5 +1,7 @@ -import { Asset, BASE_FEE, Keypair, Operation, rpc, TransactionBuilder } from '@stellar/stellar-sdk'; +import { Asset, BASE_FEE, Keypair, Operation, rpc, StrKey, TransactionBuilder } from '@stellar/stellar-sdk'; import { RELIC_ASSETS, RELIC_ISSUER, STELLAR_CONFIG } from './config'; +import { readItem, writeItem } from '../storage'; +import { retryTransient } from './transient'; const STORAGE_KEY = 'deepmark:stellar-secret:v1'; const TRUSTLINE_FLAG_PREFIX = 'deepmark:relic-trustlines:v1:'; @@ -8,7 +10,8 @@ const ACCOUNT_POLL_ATTEMPTS = 10; const TX_POLL_ATTEMPTS = 20; const POLL_INTERVAL_MS = 1500; -let cached: Promise | null = null; +let preparing: Promise | null = null; +let memoryKeypair: Keypair | null = null; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -28,19 +31,12 @@ function trustlineFlagKey(publicKey: string): string { } function trustlinesAlreadyAdded(publicKey: string): boolean { - try { - return localStorage.getItem(trustlineFlagKey(publicKey)) === '1'; - } catch { - return false; - } + return readItem(trustlineFlagKey(publicKey)) === '1'; } function rememberTrustlines(publicKey: string): void { - try { - localStorage.setItem(trustlineFlagKey(publicKey), '1'); - } catch { - // Without storage the step simply repeats next visit. It is idempotent. - } + // Without storage the step simply repeats next visit. It is idempotent. + writeItem(trustlineFlagKey(publicKey), '1'); } /** @@ -49,9 +45,9 @@ function rememberTrustlines(publicKey: string): void { * game contract only if the trustline exists, and the contract never loses a * verified run if it does not, so a failure here is not fatal. */ -async function ensureRelicTrustlines(keypair: Keypair): Promise { +async function ensureRelicTrustlines(keypair: Keypair, force = false): Promise { const publicKey = keypair.publicKey(); - if (trustlinesAlreadyAdded(publicKey)) return; + if (!force && trustlinesAlreadyAdded(publicKey)) return; const server = new rpc.Server(STELLAR_CONFIG.rpcUrl); let account = null; @@ -107,21 +103,70 @@ async function ensureRelicTrustlines(keypair: Keypair): Promise { * client depends on how the keypair was obtained. */ export function getPlayerKeypair(): Promise { - if (!cached) { - cached = (async () => { - const existingSecret = localStorage.getItem(STORAGE_KEY); - const keypair = existingSecret ? Keypair.fromSecret(existingSecret) : Keypair.random(); - if (!existingSecret) localStorage.setItem(STORAGE_KEY, keypair.secret()); - - try { - await fundOnTestnet(keypair.publicKey()); - await ensureRelicTrustlines(keypair); - } catch (err) { - console.warn('Deepmark: account preparation did not finish', err); - } - - return keypair; - })(); + preparing ??= prepareAccount(); + return preparing; +} + +/** + * Redoes the account's relic setup even if it was marked done, for a player + * whose relic mint was refused. Adding a trustline that exists is harmless. + * Throws if the setup could not finish, so the caller can say so. + */ +export async function prepareRelicSetup(): Promise { + const keypair = await getPlayerKeypair(); + await retryTransient(() => fundOnTestnet(keypair.publicKey())); + await retryTransient(() => ensureRelicTrustlines(keypair, true)); +} + +async function prepareAccount(): Promise { + const keypair = loadOrCreateKeypair(); + try { + await retryTransient(() => fundOnTestnet(keypair.publicKey())); + await retryTransient(() => ensureRelicTrustlines(keypair)); + } catch (err) { + console.warn('Deepmark: account preparation did not finish', err); + // Forget this attempt so the next caller retries funding in the same session. + preparing = null; } - return cached; + return keypair; +} + +function loadOrCreateKeypair(): Keypair { + if (memoryKeypair) return memoryKeypair; + const existingSecret = localStorage.getItem(STORAGE_KEY); + const keypair = existingSecret ? Keypair.fromSecret(existingSecret) : Keypair.random(); + if (!existingSecret) localStorage.setItem(STORAGE_KEY, keypair.secret()); + memoryKeypair = keypair; + return keypair; +} + +/** + * The account's recovery code, for the player to keep somewhere safe. It is + * the only way to get this account back after the browser's site data is + * cleared. Returns null if this browser holds no account yet. Callers must + * hand it straight to the clipboard and never log or render it. + */ +export function getRecoveryCode(): string | null { + return memoryKeypair?.secret() ?? localStorage.getItem(STORAGE_KEY); +} + +/** True when the text is a well formed recovery code. */ +export function isRecoveryCode(code: string): boolean { + return StrKey.isValidEd25519SecretSeed(code); +} + +/** The public address a recovery code belongs to, safe to show. Throws on an invalid code. */ +export function recoveryCodeAddress(code: string): string { + const trimmed = code.trim(); + if (!isRecoveryCode(trimmed)) throw new Error('That recovery code is not valid'); + return Keypair.fromSecret(trimmed).publicKey(); +} + +/** Replaces this browser's account with one restored from a recovery code. The page should reload after. */ +export function restoreRecoveryCode(code: string): void { + const trimmed = code.trim(); + if (!isRecoveryCode(trimmed)) throw new Error('That recovery code is not valid'); + localStorage.setItem(STORAGE_KEY, trimmed); + memoryKeypair = null; + preparing = null; } diff --git a/client/src/stellar/choice.ts b/client/src/stellar/choice.ts index 2110798..2653b2e 100644 --- a/client/src/stellar/choice.ts +++ b/client/src/stellar/choice.ts @@ -29,9 +29,11 @@ export const CONTRACT_ERROR = { InvalidName: 9, HeroNotFound: 10, NotHeroOwner: 11, + Frozen: 12, + NotInitialized: 13, + MintFailed: 14, } as const; export function isContractError(err: unknown, code: number): boolean { return err instanceof Error && err.message.includes(`Error(Contract, #${code})`); } - diff --git a/client/src/stellar/config.ts b/client/src/stellar/config.ts index 305e58f..f219468 100644 --- a/client/src/stellar/config.ts +++ b/client/src/stellar/config.ts @@ -4,7 +4,7 @@ export const STELLAR_CONFIG = { horizonUrl: 'https://horizon-testnet.stellar.org', friendbotUrl: 'https://friendbot.stellar.org', explorerUrl: 'https://stellar.expert/explorer/testnet', - contractId: 'CDEAFSBG7JBN6XMP4HRL7LFBL7L67K4RJK3CIRBV23W3TJQCEADZ7R6N', + contractId: 'CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX', }; export type RelicKind = 'common' | 'rare' | 'core'; @@ -16,12 +16,12 @@ export type RelicKind = 'common' | 'rare' | 'core'; * mint one. Verified on Horizon when this deployment was made. */ export const RELIC_ASSETS: Record = { - common: { code: 'DMCOMMON', contractId: 'CAAFYL2QZU274NLA2IFDB4L6SUNVQKT6OLB6UXGL5L5LMQCFJXSUYFO3' }, - rare: { code: 'DMRARE', contractId: 'CC4RQ4HLTPTXMCZ3OKDL546XXJGOQ5PI2ET6U33C2XTO4BTS6AJV4ANA' }, - core: { code: 'DMCORE', contractId: 'CC27JVKNDSUM5FDGP4JZDZTKGHJ5RJV7XHJGITTUGOUFE4DVPN6PGLMI' }, + common: { code: 'DMCOMMON', contractId: 'CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R' }, + rare: { code: 'DMRARE', contractId: 'CCQFEYZR3D4XHTMLV3XJZBTZUT4WXZS2LUXHPFTH7DRRZENZNU2NVZ5L' }, + core: { code: 'DMCORE', contractId: 'CAHKK75BOXGBKSXBB3EFPWS6C3IZIXIFLYI3A74GYCRSI4U6NKVZTLIM' }, }; -export const RELIC_ISSUER = 'GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK'; +export const RELIC_ISSUER = 'GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q'; /** Classic assets carry seven decimals. The contract mints whole relics. */ export const RELIC_UNIT = 10_000_000n; diff --git a/client/src/stellar/contract.ts b/client/src/stellar/contract.ts index b075b77..82ed56a 100644 --- a/client/src/stellar/contract.ts +++ b/client/src/stellar/contract.ts @@ -1,5 +1,6 @@ import { contract, TransactionBuilder, type Keypair, type Transaction } from '@stellar/stellar-sdk'; import type { HeroChoice } from './choice'; +import { readSentResult } from './txOutcome'; import { STELLAR_CONFIG } from './config'; export interface Look { @@ -97,10 +98,7 @@ type Call = Promise>; // contract. interface DeepmarkContractApi { start_run: (args: { player: string; choice: HeroChoice }, options?: contract.MethodOptions) => Call>; - finish_run: ( - args: { player: string; actions: Uint8Array }, - options?: contract.MethodOptions - ) => Call>; + finish_run: (args: { player: string; actions: Uint8Array }, options?: contract.MethodOptions) => Call>; get_record: (args: { player: string }, options?: contract.MethodOptions) => Call; get_open_run: (args: { player: string }, options?: contract.MethodOptions) => Call; top_fresh: (options?: contract.MethodOptions) => Call; @@ -155,12 +153,9 @@ export { CONTRACT_ERROR, freshChoice, isContractError, veteranChoice } from './c */ export async function startRun(keypair: Keypair, choice: HeroChoice): Promise { const client = await clientFor(keypair); - const assembled = await client.start_run( - { player: keypair.publicKey(), choice }, - { timeoutInSeconds: SEND_TIMEOUT_SECONDS } - ); + const assembled = await client.start_run({ player: keypair.publicKey(), choice }, { timeoutInSeconds: SEND_TIMEOUT_SECONDS }); const sent = await assembled.signAndSend(); - const result = sent.result; + const result = readSentResult(sent); if (result.isErr()) throw new Error(`start_run rejected: ${result.unwrapErr().message}`); return result.unwrap(); } @@ -182,7 +177,7 @@ export async function finishRun(keypair: Keypair, actions: readonly number[]): P { timeoutInSeconds: SEND_TIMEOUT_SECONDS } ); const sent = await assembled.signAndSend(); - const result = sent.result; + const result = readSentResult(sent); if (result.isErr()) throw new Error(`finish_run rejected: ${result.unwrapErr().message}`); return { hash: sent.sendTransactionResponse?.hash ?? '', outcome: result.unwrap() }; } @@ -225,6 +220,11 @@ export async function fetchPlayers(start: number, limit: number): Promise { + const client = await readClient(); + return (await client.hero({ id })).result ?? null; +} + /** Every delver an address owns. At most 64, so one call per delver is bounded. */ export async function fetchHeroes(owner: string): Promise { const client = await readClient(); diff --git a/client/src/stellar/lazy.ts b/client/src/stellar/lazy.ts index 760bf76..56da581 100644 --- a/client/src/stellar/lazy.ts +++ b/client/src/stellar/lazy.ts @@ -1,5 +1,9 @@ type Api = typeof import('./api'); +/** The chunk also starts account preparation, so it should begin soon after first paint. */ +const IDLE_TIMEOUT_MS = 600; +const FALLBACK_DELAY_MS = 250; + let loading: Promise | null = null; /** Loads the Stellar SDK chunk the first time it is needed. Later calls reuse it. */ @@ -14,6 +18,6 @@ export function chain(): Promise { */ export function warmChain(onReady?: () => void): void { const start = (): void => void chain().then(() => onReady?.()); - if (typeof requestIdleCallback === 'function') requestIdleCallback(start, { timeout: 2500 }); - else setTimeout(start, 1200); + if (typeof requestIdleCallback === 'function') requestIdleCallback(start, { timeout: IDLE_TIMEOUT_MS }); + else setTimeout(start, FALLBACK_DELAY_MS); } diff --git a/client/src/stellar/traction.ts b/client/src/stellar/traction.ts index 4df5ee9..84438f8 100644 --- a/client/src/stellar/traction.ts +++ b/client/src/stellar/traction.ts @@ -26,14 +26,7 @@ function playerMark(address: unknown, raw: PlayerRecord | null | undefined): Pla const deaths = count(raw.deaths); const forfeits = count(raw.forfeits); const relicsBanked = count(raw.relics_banked); - if ( - bestFloor === null || - bestScore === null || - extractions === null || - deaths === null || - forfeits === null || - relicsBanked === null - ) { + if (bestFloor === null || bestScore === null || extractions === null || deaths === null || forfeits === null || relicsBanked === null) { return null; } const finishedRuns = extractions + deaths; @@ -60,9 +53,7 @@ function parseRun(event: rpc.Api.EventResponse): RecordedRun | null { if (name !== 'run_finished') return null; const address = event.topic[1] ? scValToNative(event.topic[1]) : null; if (!isAddress(address)) return null; - const value = scValToNative(event.value) as - | { floor_reached?: unknown; score?: unknown; extracted?: unknown } - | null; + const value = scValToNative(event.value) as { floor_reached?: unknown; score?: unknown; extracted?: unknown } | null; if (!value || typeof value !== 'object') return null; const floorReached = count(value.floor_reached, 3); const score = count(value.score, 6); diff --git a/client/src/stellar/transient.ts b/client/src/stellar/transient.ts new file mode 100644 index 0000000..2db2d15 --- /dev/null +++ b/client/src/stellar/transient.ts @@ -0,0 +1,65 @@ +/** + * Tells a passing network blip from a real refusal. Kept free of the Stellar + * SDK so both the game and the account setup can share it. + */ +import { TRANSIENT_BACKOFF_MS, TRANSIENT_JITTER_MS, TRANSIENT_RETRIES } from '../constants'; + +/** HTTP statuses that mean "try again in a moment". */ +const TRANSIENT_STATUSES: readonly number[] = [429, 500, 502, 503, 504]; + +const STATUS_IN_TEXT = /\b(429|500|502|503|504)\b/; +const NETWORK_TEXT = + /failed to fetch|network ?error|network request failed|load failed|econnreset|econnrefused|etimedout|socket hang up|timed out|timeout|try_again_later|fetch failed/i; + +/** A contract refusal is a real answer from the chain, never a blip. */ +function isContractRefusal(message: string): boolean { + return message.includes('Error(Contract, #'); +} + +function statusOf(err: unknown): number | null { + if (typeof err !== 'object' || err === null) return null; + const shaped = err as { status?: unknown; response?: { status?: unknown } }; + if (typeof shaped.status === 'number') return shaped.status; + if (typeof shaped.response?.status === 'number') return shaped.response.status; + return null; +} + +/** True for a send that failed on the way (HTTP 429/5xx, reset, timeout) and may well work if repeated. */ +export function isTransientError(err: unknown): boolean { + const status = statusOf(err); + if (status !== null) return TRANSIENT_STATUSES.includes(status); + const message = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + if (!message || isContractRefusal(message)) return false; + return STATUS_IN_TEXT.test(message) || NETWORK_TEXT.test(message); +} + +export interface RetryOptions { + retries?: number; + backoffMs?: number; + jitterMs?: number; + /** Stops retrying (and returns the last error) once this says so, for example when the player skipped the wait. */ + cancelled?: () => boolean; + sleep?: (ms: number) => Promise; + random?: () => number; +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Runs `job`, repeating it after a jittered, growing wait while it fails with a transient error. */ +export async function retryTransient(job: () => Promise, options: RetryOptions = {}): Promise { + const retries = options.retries ?? TRANSIENT_RETRIES; + const backoff = options.backoffMs ?? TRANSIENT_BACKOFF_MS; + const jitter = options.jitterMs ?? TRANSIENT_JITTER_MS; + const wait = options.sleep ?? defaultSleep; + const random = options.random ?? Math.random; + for (let attempt = 0; ; attempt += 1) { + try { + return await job(); + } catch (err) { + if (attempt >= retries || !isTransientError(err) || options.cancelled?.()) throw err; + await wait(backoff * (attempt + 1) + random() * jitter); + } + } +} diff --git a/client/src/stellar/txOutcome.ts b/client/src/stellar/txOutcome.ts new file mode 100644 index 0000000..48f1aaa --- /dev/null +++ b/client/src/stellar/txOutcome.ts @@ -0,0 +1,59 @@ +/** + * Reads the outcome of a submitted transaction without trusting its shape. + * Kept free of the Stellar SDK so the game can classify failures without + * loading the heavy chunk. + */ + +/** The part of a settled transaction response this module relies on. */ +export interface SettledResponse { + status?: string; + returnValue?: unknown; +} + +/** The part of the SDK's `SentTransaction` this module relies on. */ +export interface SentLike { + getTransactionResponse?: SettledResponse | null; + readonly result: T; +} + +const STATUS_SUCCESS = 'SUCCESS'; +const STATUS_FAILED = 'FAILED'; + +/** + * A transaction that was submitted but did not produce a usable result. + * `retryable` is true only when the transaction is known to have failed on + * chain, which leaves no effect behind, so submitting a fresh one is safe. + */ +export class TxOutcomeError extends Error { + readonly retryable: boolean; + + constructor(message: string, retryable: boolean) { + super(message); + this.name = 'TxOutcomeError'; + this.retryable = retryable; + } +} + +/** + * Returns the parsed result of a sent transaction, or throws a `TxOutcomeError`. + * The SDK parses `returnValue` blindly, so a failed transaction (for example a + * footprint clash when several players start in the same ledger) would + * otherwise surface as "Cannot read properties of undefined". + */ +export function readSentResult(sent: SentLike): T { + const response = sent.getTransactionResponse; + if (response) { + if (response.status === STATUS_FAILED) { + throw new TxOutcomeError('The transaction failed on the network and had no effect', true); + } + if (response.status !== STATUS_SUCCESS || response.returnValue === undefined || response.returnValue === null) { + throw new TxOutcomeError('The network did not return a result for the transaction', false); + } + } + return sent.result; +} + +/** True when the error is a failed transaction that is safe to submit again. */ +export function isRetryableTxFailure(err: unknown): boolean { + return err instanceof TxOutcomeError && err.retryable; +} diff --git a/client/src/storage.ts b/client/src/storage.ts new file mode 100644 index 0000000..9060243 --- /dev/null +++ b/client/src/storage.ts @@ -0,0 +1,52 @@ +/** + * The only place that touches `localStorage` for optional, per-browser state. + * Storage can be missing or throw (private mode, blocked site data, quota), and + * none of the callers may crash the game because of it, so every access returns + * a value that says what happened instead of throwing. Callers that must not + * lose data silently (account secrets) do not use this module. + */ + +/** Reads a raw string, or null when the key is absent or storage is unavailable. */ +export function readItem(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** Writes a raw string. Returns false when it could not be persisted. */ +export function writeItem(key: string, value: string): boolean { + try { + localStorage.setItem(key, value); + return true; + } catch { + return false; + } +} + +/** Removes a key. Returns false when storage is unavailable. */ +export function removeItem(key: string): boolean { + try { + localStorage.removeItem(key); + return true; + } catch { + return false; + } +} + +/** Reads and parses JSON. Missing, unparsable or unavailable all give null. The caller validates the shape. */ +export function readJson(key: string): unknown { + const raw = readItem(key); + if (raw === null) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** Serializes and writes JSON. Returns false when it could not be persisted. */ +export function writeJson(key: string, value: unknown): boolean { + return writeItem(key, JSON.stringify(value)); +} diff --git a/client/src/styles.css b/client/src/styles.css index 85ccacc..0594d42 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -12,6 +12,11 @@ --danger: #ff6b5a; --safe: #8ee0a8; --shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + /* The fixed top bar (44px sound button plus its padding) and the sticky action + bar (48px buttons plus padding). Overlay screens reserve both so nothing + paints under the bar and the last row scrolls clear of the actions. */ + --topbar-h: calc(64px + env(safe-area-inset-top)); + --actions-h: calc(76px + env(safe-area-inset-bottom)); font-family: "Avenir Next", "Segoe UI", sans-serif; } @@ -42,6 +47,7 @@ button, input { font: inherit; } .topbar { position: relative; z-index: 6; + min-height: var(--topbar-h); display: flex; align-items: baseline; justify-content: space-between; @@ -270,7 +276,41 @@ body:has(#overlay:not([hidden])) .touch { gap: 8px; } -.panel-scroll { max-height: 100%; overflow: auto; } +/* The overlay scrolls, not the card, so a sticky action bar pins to the screen. */ +.panel-scroll { max-height: none; } + +/* Cards that sit under the top bar (creator, vault, leaderboard, record): a + column that scrolls, offset by the bar, with the card centred by auto margins + so a card taller than the screen starts at the top instead of being clipped. */ +.overlay:has(> .panel, > .vault-view) { + display: flex; + flex-direction: column; + align-items: center; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + scroll-padding: var(--topbar-h) 0 var(--actions-h); + padding: calc(var(--topbar-h) + 8px) calc(16px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); +} +.overlay:has(> .panel, > .vault-view) > .panel { flex: none; margin: auto; } +.overlay:has(> .vault-view) > .vault-view { flex: 1 0 auto; } + +/* Scrolled content passes under the top bar, so the bar backs itself with the page colour. */ +body:has(#overlay > .panel, #overlay > .vault-view) .topbar { + background: linear-gradient(180deg, var(--bg) 78%, rgba(7, 8, 14, 0)); +} + +/* The way out of a long screen stays pinned to the bottom edge. */ +.sticky-actions { + position: sticky; + bottom: 0; + z-index: 2; + min-height: var(--actions-h); + padding: 12px 0 calc(8px + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, rgba(10, 13, 21, 0) 0%, rgba(10, 13, 21, 0.94) 32%); +} +.sticky-actions .btn { min-height: 48px; } .prompt-danger { border-color: rgba(255, 107, 90, 0.45); } .prompt-safe { border-color: rgba(142, 224, 168, 0.4); } @@ -335,11 +375,19 @@ body:has(#overlay:not([hidden])) .touch { .quiet-link { display: inline-flex; align-items: center; + justify-content: center; min-height: 44px; + min-width: 44px; color: var(--cyan); text-decoration: none; } +.quiet-links { + display: flex; + flex-wrap: wrap; + gap: 4px 20px; +} + .quiet-link:hover { text-decoration: underline; } .quiet-link:focus-visible { @@ -751,20 +799,13 @@ body:has(#overlay:not([hidden])) .touch { /* The preview stays in view while choosing, and the action row stays reachable. */ .creator-preview { position: sticky; - top: -16px; + top: var(--topbar-h); z-index: 2; margin: 0 -4px; backdrop-filter: blur(6px); background: rgba(10, 12, 20, 0.92); } -.creator .title-actions { - position: sticky; - bottom: -16px; - z-index: 2; - padding: 10px 0 2px; - background: linear-gradient(180deg, rgba(12, 16, 26, 0), rgba(12, 16, 26, 0.96) 35%); -} /* Descent scene */ .descent { @@ -1082,12 +1123,10 @@ body:has(#overlay:not([hidden])) .touch { 14 second settle is a compositor-only animation instead of a repaint on every frame. The loss screen has no image backdrop here: it sinks the last drawn board frame instead (see `canvas.board-sunk` and `.overlay:has(.result-loss)` further down). */ -.overlay:has(.result-win) { - overflow: hidden; -} .overlay:has(.result-win)::before { content: ""; - position: absolute; + position: fixed; + pointer-events: none; inset: -6% 0; z-index: -1; background-size: cover; @@ -1105,6 +1144,25 @@ body:has(#overlay:not([hidden])) .touch { } .result { display: flex; flex-direction: column; gap: 10px; width: min(100%, 520px); } + +/* The result screen scrolls on any short or narrow viewport. A grid with + centred items clips the top of an overflowing card where no scroll can reach + it, so the overlay is a column and the card centres itself with `margin: auto`, + which falls back to the top edge when the card is taller than the screen. + It sits over the top bar (z-index 7, as the title does) so nothing scrolls + under the sound button. */ +.overlay:has(.result) { + z-index: 7; + display: flex; + flex-direction: column; + align-items: center; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: calc(16px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); +} +.overlay:has(.result) > .result { flex: none; margin: auto; } /* The board sinks first; the card follows 300ms later instead of both at once. */ .result-loss { opacity: 0; animation: rise 500ms ease 300ms both; } .result-eyebrow { margin: 0; color: var(--muted); font-size: 0.74rem; letter-spacing: 0.22em; text-transform: uppercase; animation: rise 600ms ease both; } @@ -1172,8 +1230,8 @@ body:has(#overlay:not([hidden])) .touch { .share-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; animation: rise 500ms ease both; } -.result-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; } - +.result-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 4px; } +.result-actions .btn { flex: 1 1 140px; min-height: 48px; } @media (max-width: 520px) { .result-stats { grid-template-columns: repeat(2, 1fr); } .seal { width: 52px; height: 52px; } @@ -1333,3 +1391,71 @@ body:has(#overlay:not([hidden])) .touch { .pick-icon { width: 56px; height: 56px; object-fit: contain; flex: 0 0 auto; filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5)); } .vault-icons { display: flex; gap: 6px; margin: 6px 0 2px; } .vault-icon { width: 30px; height: 30px; object-fit: contain; opacity: 0.9; } + +/* The calm way out of a long wait on the descent scene. */ +.descent-slow { margin: 0; max-width: 44ch; color: var(--muted); font-size: 0.85rem; } +.descent-slow[hidden], .descent-skip[hidden], .record-nudge[hidden] { display: none; } +.descent-skip { margin-top: 4px; } +.record-nudge { margin: 0; color: var(--muted); font-size: 0.85rem; } + +/* Phones. Nothing floats over the page title, the board or the controls. */ +.floor-label:empty { display: none; } + +/* Scrolled cards pass under the bar, so it is solid instead of a fade that lets text show through. */ +body:has(#overlay > .panel, #overlay > .vault-view) .topbar { + background: var(--bg); + box-shadow: 0 8px 12px -8px rgba(0, 0, 0, 0.7); +} + +@media (max-width: 899px) { + .topbar { flex-wrap: wrap; row-gap: 0; } + .floor-label { order: 3; flex: 1 0 100%; font-size: 0.78rem; margin-top: -6px; } + /* The board sits in the middle of the space above the controls, so there is no empty block under the log. */ + .stage-wrap { flex: 1 1 auto; place-items: center; } + .hud { padding-top: 6px; } + .hud-log { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-height: 2.6em; } + /* The first-run hint sits in the free space above the controls, not on the board. */ + .hint-card { + position: fixed; + left: 12px; + right: 12px; + bottom: calc(160px + env(safe-area-inset-bottom)); + max-width: none; + z-index: 4; + } +} + +/* Short, wide screens (a phone on its side): board on the left, status and controls on the right, nothing below the fold. */ +@media (max-width: 899px) and (orientation: landscape) and (max-height: 520px) { + #app { + display: grid; + grid-template-columns: minmax(0, 1fr) 250px; + grid-template-rows: auto minmax(0, 1fr); + height: 100dvh; + min-height: 0; + overflow: hidden; + } + .topbar { grid-column: 1 / -1; grid-row: 1; flex-wrap: nowrap; padding-bottom: 2px; } + .floor-label { order: 0; flex: 0 1 auto; margin: 0; } + .stage-wrap { grid-column: 1; grid-row: 2; padding: 0 8px 8px; place-items: center; } + .stage { width: min(100%, calc((100dvh - 80px) * 832 / 576)); } + .hud, .touch { grid-column: 2; grid-row: 2; width: auto; margin: 0; padding: 0 12px 8px 4px; } + .hud { align-self: start; } + .hud-stats { gap: 2px 14px; } + #hud-name { flex-basis: 100%; } + .hud-stats > * { white-space: nowrap; } + .hud-key { display: none; } + .hud-log { font-size: 0.8rem; -webkit-line-clamp: 3; min-height: 0; } + .touch { align-self: end; margin-top: 0; gap: 10px; } + .pad { grid-template-columns: repeat(3, 46px); grid-template-rows: repeat(2, 46px); gap: 4px; } + .pad-btn, .touch-actions .btn { min-width: 46px; min-height: 46px; } + .touch-actions { gap: 6px; } + .hint-card { left: auto; right: 8px; width: 234px; bottom: 128px; padding: 6px 10px; } + .hint-body { font-size: 0.78rem; } + /* The creator's preview scrolls with the form here, so it never covers the Name field. */ + .creator-preview { position: static; } + /* The title keeps its menu on screen. */ + .title-logo { width: auto; max-height: 30dvh; } + .title-content { gap: 8px; padding-top: 12px; padding-bottom: 12px; } + .title-hint { display: none; } +} diff --git a/client/tests/account.test.ts b/client/tests/account.test.ts new file mode 100644 index 0000000..a48191d --- /dev/null +++ b/client/tests/account.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { Keypair } from '@stellar/stellar-sdk'; +import { getRecoveryCode, isRecoveryCode, recoveryCodeAddress, restoreRecoveryCode } from '../src/stellar/account'; +import { installMemoryStorage } from './storage'; + +describe('recovery code', () => { + beforeEach(() => { + installMemoryStorage(); + }); + + test('accepts a real secret seed and rejects other text', () => { + expect(isRecoveryCode(Keypair.random().secret())).toBe(true); + expect(isRecoveryCode(Keypair.random().publicKey())).toBe(false); + expect(isRecoveryCode('not a code')).toBe(false); + }); + + test('restoring stores the code and rejects invalid input', () => { + const code = Keypair.random().secret(); + restoreRecoveryCode(` ${code}\n`); + expect(getRecoveryCode()).toBe(code); + expect(() => restoreRecoveryCode('nope')).toThrow(); + }); + + test('a rejected code leaves the current account untouched', () => { + const current = Keypair.random().secret(); + restoreRecoveryCode(current); + for (const bad of ['', ' ', 'nope', Keypair.random().publicKey(), `${current}x`, current.slice(1)]) { + expect(() => restoreRecoveryCode(bad)).toThrow(); + } + expect(getRecoveryCode()).toBe(current); + }); + + test('the shown address matches the code and never contains the secret', () => { + const pair = Keypair.random(); + const address = recoveryCodeAddress(` ${pair.secret()} `); + expect(address).toBe(pair.publicKey()); + expect(address).not.toContain(pair.secret()); + expect(() => recoveryCodeAddress('nope')).toThrow(); + }); +}); diff --git a/client/tests/chainQueue.test.ts b/client/tests/chainQueue.test.ts new file mode 100644 index 0000000..306fe7f --- /dev/null +++ b/client/tests/chainQueue.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from 'bun:test'; +import { ChainQueue, sleep, withTimeout } from '../src/app/chainQueue'; +import { describeFailure, type FailureKind, failureKind, isPermanentFailure, keepsRun, SettlementUnconfirmed } from '../src/app/failures'; +import { CONTRACT_ERROR } from '../src/stellar/choice'; + +const contractError = (code: number): Error => new Error(`HostError: Error(Contract, #${code})`); + +describe('ChainQueue', () => { + test('runs jobs one at a time, in order', async () => { + const queue = new ChainQueue(); + const log: string[] = []; + const job = (name: string, ms: number) => async () => { + log.push(`${name}:start`); + await sleep(ms); + log.push(`${name}:end`); + return name; + }; + const results = await Promise.all([queue.enqueue(job('a', 20)), queue.enqueue(job('b', 1)), queue.enqueue(job('c', 1))]); + expect(results).toEqual(['a', 'b', 'c']); + expect(log).toEqual(['a:start', 'a:end', 'b:start', 'b:end', 'c:start', 'c:end']); + }); + + test('a job holds the queue through its whole retry loop, sleeps included', async () => { + const queue = new ChainQueue(); + const log: string[] = []; + const finishRun = queue.enqueue(async () => { + for (let attempt = 0; attempt < 3; attempt += 1) { + log.push(`finish:${attempt}`); + await sleep(5); + } + }); + const startRun = queue.enqueue(async () => { + log.push('start'); + }); + await Promise.all([finishRun, startRun]); + expect(log).toEqual(['finish:0', 'finish:1', 'finish:2', 'start']); + }); + + test('a failed job rejects its own caller but does not block the next one', async () => { + const queue = new ChainQueue(); + const failing = queue.enqueue(async () => { + throw new Error('boom'); + }); + const next = queue.enqueue(async () => 'ok'); + await expect(failing).rejects.toThrow('boom'); + expect(await next).toBe('ok'); + }); +}); + +describe('withTimeout', () => { + test('passes through a value that arrives in time', async () => { + expect(await withTimeout(Promise.resolve(7), 50)).toBe(7); + }); + + test('passes through the original rejection', async () => { + await expect(withTimeout(Promise.reject(new Error('nope')), 50)).rejects.toThrow('nope'); + }); + + test('rejects when the promise is too slow', async () => { + await expect(withTimeout(sleep(100), 5)).rejects.toThrow('Timed out'); + }); +}); + +describe('failure classification', () => { + test('contract refusals are permanent, except TooFast which heals by waiting', () => { + expect(isPermanentFailure(contractError(CONTRACT_ERROR.RunNotStarted))).toBe(true); + expect(isPermanentFailure(contractError(CONTRACT_ERROR.InvalidAction))).toBe(true); + expect(isPermanentFailure(contractError(CONTRACT_ERROR.TooFast))).toBe(false); + }); + + test('network trouble and non errors are not permanent', () => { + expect(isPermanentFailure(new Error('fetch failed'))).toBe(false); + expect(isPermanentFailure('Error(Contract, #2)')).toBe(false); + }); + + test('each refusal gets its own sentence, anything else says Stellar was unreachable', () => { + expect(describeFailure(contractError(CONTRACT_ERROR.RunNotStarted))).toContain('no longer open'); + expect(describeFailure(contractError(CONTRACT_ERROR.NotHeroOwner))).toContain('changed hands'); + expect(describeFailure(contractError(CONTRACT_ERROR.TrailingActions))).toContain('could not reproduce'); + expect(describeFailure(new Error('timeout'))).toContain('could not be reached'); + }); +}); + +describe('failure kinds, one per contract code', () => { + const expected: Record = { + 1: 'permanent', + 2: 'closed', + 3: 'permanent', + 4: 'permanent', + 5: 'permanent', + 6: 'permanent', + 7: 'wait', + 8: 'permanent', + 9: 'permanent', + 10: 'permanent', + 11: 'permanent', + 12: 'permanent', + 13: 'notReady', + 14: 'mint', + }; + for (const [code, kind] of Object.entries(expected)) { + test(`code ${code} is ${kind}`, () => { + expect(failureKind(contractError(Number(code)))).toBe(kind); + }); + } + + test('a rejected mint keeps the run and can be retried', () => { + const err = contractError(CONTRACT_ERROR.MintFailed); + expect(isPermanentFailure(err)).toBe(false); + expect(keepsRun(err)).toBe(true); + expect(describeFailure(err)).toContain('relics could not be delivered'); + }); + + test('an uninitialised contract keeps the run and says so', () => { + const err = contractError(CONTRACT_ERROR.NotInitialized); + expect(isPermanentFailure(err)).toBe(false); + expect(keepsRun(err)).toBe(true); + expect(describeFailure(err)).toContain('not ready'); + }); + + test('a refusal that cannot heal drops the run', () => { + expect(keepsRun(contractError(CONTRACT_ERROR.InvalidAction))).toBe(false); + expect(keepsRun(contractError(CONTRACT_ERROR.RunNotStarted))).toBe(false); + }); + + test('an unconfirmed settlement is retryable and honest', () => { + const err = new SettlementUnconfirmed(); + expect(failureKind(err)).toBe('wait'); + expect(keepsRun(err)).toBe(true); + expect(describeFailure(err)).toContain('may already be recorded'); + }); +}); diff --git a/client/tests/delverOutcome.test.ts b/client/tests/delverOutcome.test.ts new file mode 100644 index 0000000..bddbe67 --- /dev/null +++ b/client/tests/delverOutcome.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; +import { delverGrewKicker, delverOutcome } from '../src/game/delverOutcome'; + +describe('delverOutcome', () => { + test('no hero id means nothing happened to a delver', () => { + expect(delverOutcome(null, false, null)).toEqual({ kind: 'none' }); + expect(delverOutcome(null, true, null)).toEqual({ kind: 'none' }); + }); + + test('a fresh delver clearing all floors earns a new delver', () => { + expect(delverOutcome(4, false, null)).toEqual({ kind: 'earned' }); + }); + + test('a veteran clear never claims a new delver, only a grown record', () => { + expect(delverOutcome(1, true, 2)).toEqual({ kind: 'grew', clears: 2 }); + expect(delverOutcome(1, true, null)).toEqual({ kind: 'grew', clears: null }); + }); + + test('the veteran kicker is accurate and never says a new delver', () => { + expect(delverGrewKicker(2)).toBe("Your delver's record grew: 2 clears"); + expect(delverGrewKicker(1)).toBe("Your delver's record grew: 1 clear"); + expect(delverGrewKicker(null)).toBe("Your delver's record grew"); + expect(delverGrewKicker(3).toLowerCase()).not.toContain('new delver'); + }); +}); diff --git a/client/tests/docsSite.test.ts b/client/tests/docsSite.test.ts new file mode 100644 index 0000000..eaf9edf --- /dev/null +++ b/client/tests/docsSite.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { buildSite } from '../docs-site/site'; +import { resolveHref, type LinkContext } from '../docs-site/links'; +import { PAGES, pageUrl } from '../docs-site/manifest'; + +const EM_DASH = String.fromCharCode(0x2014); +const site = buildSite(); + +function html(path: string): string { + const content = site.get(path); + if (typeof content !== 'string') throw new Error(`missing ${path}`); + return content; +} + +describe('docs site', () => { + test('the index and every navigation page exist', () => { + expect(site.has('docs/index.html')).toBe(true); + for (const page of PAGES) expect(site.has(`docs/${page.slug}/index.html`)).toBe(true); + }); + + test('no generated text contains an em dash', () => { + for (const [path, content] of site) { + if (typeof content === 'string') expect(content.includes(EM_DASH), path).toBe(false); + } + }); + + test('pages ship no script and keep their SEO tags', () => { + for (const page of PAGES) { + const doc = html(`docs/${page.slug}/index.html`); + expect(doc.includes('`); + expect(doc).toContain('property="og:title"'); + expect(doc).toContain('name="description"'); + expect(doc).toContain('class="skip"'); + } + }); + + test('every local image and stylesheet a page references was emitted', () => { + for (const [path, content] of site) { + if (!path.endsWith('.html') || typeof content !== 'string') continue; + for (const match of content.matchAll(/(?:src|href)="(\/docs\/(?:img|assets)\/[^"#]+)"/g)) { + const target = match[1]; + if (target) expect(site.has(target.slice(1)), `${path} -> ${target}`).toBe(true); + } + } + }); + + test('explorer links on the contracts page are clickable', () => { + expect(html('docs/contracts/index.html')).toContain('href="https://stellar.expert/explorer/testnet/contract/'); + }); + + test('a broken internal link fails loudly', () => { + const ctx: LinkContext = { anchors: new Map(), diagramStems: new Set(), images: new Map() }; + expect(() => resolveHref('docs/does-not-exist.md', 'README.md', 'overview', ctx)).toThrow('broken link'); + expect(resolveHref('LICENSE', 'README.md', 'overview', ctx)).toBe('https://github.com/ZephyRoute/deepmark/blob/main/LICENSE'); + }); +}); diff --git a/client/tests/game.test.ts b/client/tests/game.test.ts new file mode 100644 index 0000000..42fa749 --- /dev/null +++ b/client/tests/game.test.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { resolveExchange } from '../src/game/combat'; +import { loadDraft, saveDraft } from '../src/game/draft'; +import { MAX_FLOOR } from '../src/game/floor'; +import { CLOAK_COUNT, EMBLEM_COUNT, isValidLoadout, isValidName, MAX_RANK, rankForClears, statsFor } from '../src/game/loadout'; +import { loadProgress, recordRun } from '../src/game/progress'; +import { mulberry32, randInt } from '../src/game/rng'; +import { ActionCode, applyAction, drainEvents, MAX_ACTIONS, newRun } from '../src/game/state'; +import { playBalanced } from '../scripts/policy'; +import { installMemoryStorage } from './storage'; + +const LOADOUT = { base: 0, weapon: 0, charm: 0, rank: 0 }; + +describe('rng', () => { + test('the same seed gives the same sequence, a different seed does not', () => { + const a = mulberry32(42); + const b = mulberry32(42); + const c = mulberry32(43); + const first = Array.from({ length: 8 }, () => a()); + expect(Array.from({ length: 8 }, () => b())).toEqual(first); + expect(Array.from({ length: 8 }, () => c())).not.toEqual(first); + }); + + test('values stay in [0, 1) and a seed outside 32 bits wraps like the contract', () => { + const rng = mulberry32(7); + for (let i = 0; i < 1000; i += 1) { + const value = rng(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + expect(mulberry32(0x1_0000_0005)()).toBe(mulberry32(5)()); + }); + + test('randInt is inclusive at both ends and covers the range', () => { + const rng = mulberry32(99); + const seen = new Set(); + for (let i = 0; i < 500; i += 1) seen.add(randInt(rng, 2, 5)); + expect([...seen].sort()).toEqual([2, 3, 4, 5]); + expect(randInt(rng, 3, 3)).toBe(3); + }); +}); + +describe('combat', () => { + const low = () => 0; + const high = () => 0.999999; + + test('the player strikes first and a killing blow ends the exchange with no reply', () => { + const result = resolveExchange(low, 10, 2, 3, 3, 2, 4); + expect(result).toEqual({ playerDamageDealt: 2, enemyDamageDealt: 0, enemyDied: true, playerDied: false }); + }); + + test('a surviving enemy hits back using its own damage range', () => { + const result = resolveExchange(high, 10, 20, 1, 4, 1, 3); + expect(result.playerDamageDealt).toBe(3); + expect(result.enemyDamageDealt).toBe(4); + expect(result.enemyDied).toBe(false); + expect(result.playerDied).toBe(false); + }); + + test('the player dies when the reply takes the last hit point', () => { + expect(resolveExchange(high, 4, 20, 1, 4, 1, 3).playerDied).toBe(true); + expect(resolveExchange(high, 5, 20, 1, 4, 1, 3).playerDied).toBe(false); + }); + + test('exact lethal damage counts as a kill', () => { + expect(resolveExchange(low, 10, 3, 1, 1, 3, 3).enemyDied).toBe(true); + }); +}); + +describe('loadout rules', () => { + test('accepts every in range kit and rejects out of range or fractional ones', () => { + expect(isValidLoadout(LOADOUT)).toBe(true); + expect(isValidLoadout({ ...LOADOUT, base: 99 })).toBe(false); + expect(isValidLoadout({ ...LOADOUT, weapon: -1 })).toBe(false); + expect(isValidLoadout({ ...LOADOUT, charm: 1.5 })).toBe(false); + expect(isValidLoadout({ ...LOADOUT, rank: MAX_RANK + 1 })).toBe(false); + }); + + test('rank follows clears and is capped', () => { + expect(rankForClears(-4)).toBe(0); + expect(rankForClears(2)).toBe(2); + expect(rankForClears(50)).toBe(MAX_RANK); + }); + + test('each rank adds one hit point and nothing else', () => { + const base = statsFor(LOADOUT); + const veteran = statsFor({ ...LOADOUT, rank: 2 }); + expect(veteran.maxHp).toBe(base.maxHp + 2); + expect(veteran.dmgMin).toBe(base.dmgMin); + expect(veteran.dmgMax).toBe(base.dmgMax); + expect(statsFor({ ...LOADOUT, rank: 99 }).maxHp).toBe(statsFor({ ...LOADOUT, rank: MAX_RANK }).maxHp); + }); + + test('stats never fall below 1 and damage max never falls under min', () => { + for (let base = 0; base < 2; base += 1) { + for (let weapon = 0; weapon < 3; weapon += 1) { + for (let charm = 0; charm < 3; charm += 1) { + const stats = statsFor({ base, weapon, charm, rank: 0 }); + expect(stats.maxHp).toBeGreaterThanOrEqual(1); + expect(stats.dmgMin).toBeGreaterThanOrEqual(1); + expect(stats.dmgMax).toBeGreaterThanOrEqual(stats.dmgMin); + } + } + } + }); + + test('an invalid loadout throws instead of producing stats', () => { + expect(() => statsFor({ ...LOADOUT, base: 99 })).toThrow('Invalid loadout'); + }); + + test('names are 3 to 16 letters or digits with single spaces', () => { + expect(isValidName('Ash')).toBe(true); + expect(isValidName('Mara Voss')).toBe(true); + expect(isValidName('Al')).toBe(false); + expect(isValidName('a'.repeat(17))).toBe(false); + expect(isValidName('Two Spaces')).toBe(false); + expect(isValidName(' lead')).toBe(false); + expect(isValidName('no-dash')).toBe(false); + }); +}); + +describe('run state', () => { + test('a new run starts exploring on floor 1 at full health with no actions', () => { + const run = newRun(LOADOUT, 5); + expect(run.mode).toBe('explore'); + expect(run.floorNumber).toBe(1); + expect(run.player.hp).toBe(run.player.maxHp); + expect(run.actions).toEqual([]); + expect(run.resultKind).toBeNull(); + }); + + test('input with no effect is not recorded', () => { + const run = newRun(LOADOUT, 5); + expect(applyAction(run, ActionCode.Attack)).toBe(false); + expect(applyAction(run, ActionCode.Descend)).toBe(false); + expect(applyAction(run, ActionCode.Extract)).toBe(false); + expect(run.actions).toEqual([]); + }); + + test('walking into a wall does not move or record', () => { + const run = newRun(LOADOUT, 5); + for (let i = 0; i < 40; i += 1) applyAction(run, ActionCode.Up); + const recorded = run.actions.length; + const { x, y } = run.player; + expect(applyAction(run, ActionCode.Up)).toBe(false); + expect(run.player).toMatchObject({ x, y }); + expect(run.actions.length).toBe(recorded); + }); + + test('every recorded action is a valid code and the same seed replays to the same result', () => { + const first = playBalanced(newRun(LOADOUT, 1234), Infinity); + const second = newRun(LOADOUT, 1234); + for (const code of first.actions) applyAction(second, code as ActionCode); + expect(second.mode).toBe(first.mode); + expect(second.resultKind).toBe(first.resultKind); + expect(second.scoreThisRun).toBe(first.scoreThisRun); + expect(second.player.hp).toBe(first.player.hp); + expect(second.floorNumber).toBe(first.floorNumber); + }); + + test('a finished run refuses further actions', () => { + const run = playBalanced(newRun(LOADOUT, 1234), Infinity); + expect(run.mode).toBe('result'); + const recorded = run.actions.length; + expect(applyAction(run, ActionCode.Up)).toBe(false); + expect(run.actions.length).toBe(recorded); + }); + + test('a death forfeits relics and score, an extraction keeps them', () => { + let died = false; + let extracted = false; + for (let seed = 1; seed < 200 && !(died && extracted); seed += 1) { + const run = playBalanced(newRun(LOADOUT, seed), Infinity); + if (run.resultKind === 'dead') { + died = true; + expect(run.relicsThisRun).toBe(0); + expect(run.scoreThisRun).toBe(0); + } + if (run.resultKind === 'extracted') { + extracted = true; + expect(run.floorNumber).toBeLessThanOrEqual(MAX_FLOOR); + } + } + expect(died).toBe(true); + expect(extracted).toBe(true); + }); + + test('draining events empties the queue and returns them in order', () => { + const run = playBalanced(newRun(LOADOUT, 1234), 30); + const events = drainEvents(run); + expect(events.length).toBeGreaterThan(0); + expect(drainEvents(run)).toEqual([]); + }); + + test('the action log is capped and flags exhaustion', () => { + const run = newRun(LOADOUT, 5); + const moves = [ActionCode.Left, ActionCode.Right, ActionCode.Up, ActionCode.Down]; + const pairs: [ActionCode, ActionCode][] = [ + [moves[0] as ActionCode, moves[1] as ActionCode], + [moves[2] as ActionCode, moves[3] as ActionCode], + ]; + const pair = pairs.find(([a]) => applyAction(newRun(LOADOUT, 5), a)); + expect(pair).toBeDefined(); + const [forward, back] = pair as [ActionCode, ActionCode]; + for (let i = 0; i < MAX_ACTIONS * 2 && !run.exhausted && run.mode === 'explore'; i += 1) { + applyAction(run, i % 2 === 0 ? forward : back); + } + expect(run.actions.length).toBeLessThanOrEqual(MAX_ACTIONS); + if (run.actions.length === MAX_ACTIONS) { + expect(applyAction(run, forward)).toBe(false); + expect(run.exhausted).toBe(true); + } + }); +}); + +describe('local progress', () => { + beforeEach(() => { + installMemoryStorage(); + }); + + test('an empty browser starts from zero', () => { + expect(loadProgress().runsPlayed).toBe(0); + }); + + test('an extraction books floor, relics, score and delver, and flags a new best', () => { + const { progress, isNewBest } = recordRun({ + floorReached: 3, + relicsKept: 4, + score: 120, + extracted: true, + heroId: 'cinder', + practice: false, + }); + expect(isNewBest).toBe(true); + expect(progress).toMatchObject({ + runsPlayed: 1, + extractions: 1, + deaths: 0, + bestFloor: 3, + bestRelics: 4, + bestScore: 120, + cinderRuns: 1, + }); + expect(loadProgress()).toEqual(progress); + }); + + test('a death keeps no relics or score and is not a new best by relics', () => { + recordRun({ floorReached: 2, relicsKept: 3, score: 50, extracted: true, heroId: 'lantern', practice: false }); + const { progress, isNewBest } = recordRun({ + floorReached: 2, + relicsKept: 9, + score: 99, + extracted: false, + heroId: 'lantern', + practice: false, + }); + expect(isNewBest).toBe(false); + expect(progress).toMatchObject({ deaths: 1, relicsKept: 3, bestRelics: 3, bestScore: 50, runsPlayed: 2 }); + }); + + test('a practice run never counts toward this browser bests', () => { + const store = (globalThis.localStorage as Storage).getItem.bind(globalThis.localStorage); + const before = store('deepmark:progress:v1'); + const { progress, isNewBest } = recordRun({ + floorReached: 3, + relicsKept: 5, + score: 200, + extracted: true, + heroId: 'lantern', + practice: true, + }); + expect(isNewBest).toBe(false); + expect(progress).toMatchObject({ runsPlayed: 0, bestFloor: 0, bestRelics: 0, bestScore: 0, extractions: 0 }); + expect(store('deepmark:progress:v1')).toBe(before); + recordRun({ floorReached: 2, relicsKept: 1, score: 10, extracted: true, heroId: 'lantern', practice: false }); + const after = recordRun({ floorReached: 3, relicsKept: 9, score: 900, extracted: true, heroId: 'lantern', practice: true }); + expect(after.progress).toMatchObject({ runsPlayed: 1, bestFloor: 2, bestRelics: 1 }); + }); + + test('stored garbage is sanitised to zeros rather than trusted', () => { + (globalThis.localStorage as Storage).setItem('deepmark:progress:v1', JSON.stringify({ bestFloor: 99, runsPlayed: -5, bestScore: 'x' })); + expect(loadProgress()).toMatchObject({ bestFloor: 0, runsPlayed: 0, bestScore: 0 }); + }); +}); + +describe('creator draft', () => { + beforeEach(() => { + installMemoryStorage(); + }); + + test('a saved draft round trips', () => { + const draft = { look: { name: 'Corin', emblem: 2, cloak: 3 }, base: 1, weapon: 1, charm: 2 }; + saveDraft(draft); + expect(loadDraft()).toEqual(draft); + }); + + test('a tampered draft falls back to a valid default', () => { + const bad = { look: { name: 'x', emblem: EMBLEM_COUNT, cloak: CLOAK_COUNT }, base: 99, weapon: 0, charm: 0 }; + (globalThis.localStorage as Storage).setItem('deepmark:creator:v1', JSON.stringify(bad)); + const draft = loadDraft(); + expect(isValidName(draft.look.name)).toBe(true); + expect(draft.base).toBeLessThan(2); + }); +}); diff --git a/client/tests/inputGuard.test.ts b/client/tests/inputGuard.test.ts new file mode 100644 index 0000000..6bc9b04 --- /dev/null +++ b/client/tests/inputGuard.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test'; +import { RESULT_INPUT_GUARD_MS, resultGuardActive } from '../src/input/guard'; + +describe('result input guard', () => { + test('ignores input right after the result appears', () => { + expect(resultGuardActive(1000, 1000)).toBe(true); + expect(resultGuardActive(1000, 1000 + RESULT_INPUT_GUARD_MS - 1)).toBe(true); + }); + + test('accepts input once the guard has elapsed', () => { + expect(resultGuardActive(1000, 1000 + RESULT_INPUT_GUARD_MS)).toBe(false); + }); +}); diff --git a/client/tests/kitDifficulty.test.ts b/client/tests/kitDifficulty.test.ts index 4024658..2664eb8 100644 --- a/client/tests/kitDifficulty.test.ts +++ b/client/tests/kitDifficulty.test.ts @@ -18,7 +18,9 @@ const MAX_CLEAR_PERCENT = 60; function allKits(): { base: number; weapon: number; charm: number; percent: number }[] { return HERO_ORDER.flatMap((_, base) => - WEAPONS.flatMap((_, weapon) => CHARMS.map((_, charm) => ({ base, weapon, charm, percent: clearPercent({ base, weapon, charm }) ?? NaN }))) + WEAPONS.flatMap((_, weapon) => + CHARMS.map((_, charm) => ({ base, weapon, charm, percent: clearPercent({ base, weapon, charm }) ?? NaN })) + ) ); } diff --git a/client/tests/recordNudge.test.ts b/client/tests/recordNudge.test.ts new file mode 100644 index 0000000..b0419cc --- /dev/null +++ b/client/tests/recordNudge.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; +import { shouldNudgeRecord } from '../src/game/recordNudge'; + +const FIRST_WIN = { practice: false, verified: true, won: true, verifiedRuns: 1 }; + +describe('keep your record nudge', () => { + test('shows after a fresh account verified first win', () => { + expect(shouldNudgeRecord(FIRST_WIN)).toBe(true); + }); + + test('stays away for a loss, a practice run, an unverified run or a returning account', () => { + expect(shouldNudgeRecord({ ...FIRST_WIN, won: false })).toBe(false); + expect(shouldNudgeRecord({ ...FIRST_WIN, practice: true })).toBe(false); + expect(shouldNudgeRecord({ ...FIRST_WIN, verified: false })).toBe(false); + expect(shouldNudgeRecord({ ...FIRST_WIN, verifiedRuns: 2 })).toBe(false); + expect(shouldNudgeRecord({ ...FIRST_WIN, verifiedRuns: null })).toBe(false); + }); +}); diff --git a/client/tests/savedRun.test.ts b/client/tests/savedRun.test.ts index 98250ce..c0d2d17 100644 --- a/client/tests/savedRun.test.ts +++ b/client/tests/savedRun.test.ts @@ -86,7 +86,11 @@ describe('replaying a saved run', () => { describe('saved data', () => { test('stays far below the size cap even for the longest run the contract accepts', () => { - const longest: SavedRun = { seed: 0xffff_ffff, loadout: { base: 1, weapon: 2, charm: 2, rank: 3 }, actions: Array(MAX_ACTIONS).fill(ActionCode.Retreat) }; + const longest: SavedRun = { + seed: 0xffff_ffff, + loadout: { base: 1, weapon: 2, charm: 2, rank: 3 }, + actions: Array(MAX_ACTIONS).fill(ActionCode.Retreat), + }; const run = newRun(longest.loadout, longest.seed, LOOK); run.actions = longest.actions; expect(serializeSavedRun(run).length).toBeLessThan(MAX_SAVED_BYTES); diff --git a/client/tests/settlement.test.ts b/client/tests/settlement.test.ts new file mode 100644 index 0000000..684beef --- /dev/null +++ b/client/tests/settlement.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import { checkSettlement } from '../src/app/settlement'; +import type { PlayerRecord } from '../src/stellar/contract'; + +function record(totalRuns: number): PlayerRecord { + return { total_runs: totalRuns } as PlayerRecord; +} + +describe('checkSettlement', () => { + test('a run count past the baseline proves the earlier send landed', async () => { + expect(await checkSettlement(async () => record(4), 3)).toBe('recorded'); + }); + + test('an unchanged count means nothing was recorded', async () => { + expect(await checkSettlement(async () => record(3), 3)).toBe('notRecorded'); + expect(await checkSettlement(async () => null, 0)).toBe('notRecorded'); + }); + + test('no baseline or an unreadable record cannot be confirmed', async () => { + expect(await checkSettlement(async () => record(9), null)).toBe('unknown'); + expect( + await checkSettlement(async () => { + throw new Error('timeout'); + }, 3) + ).toBe('unknown'); + }); +}); diff --git a/client/tests/skipGate.test.ts b/client/tests/skipGate.test.ts new file mode 100644 index 0000000..ce0024e --- /dev/null +++ b/client/tests/skipGate.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { raceSkip, SKIPPED, SkipGate } from '../src/app/skipGate'; +import { ChainQueue } from '../src/app/chainQueue'; +import { waitState } from '../src/render/descentWait'; +import { SKIP_OFFER_AFTER_MS, SLOW_NOTE_AFTER_MS } from '../src/constants'; + +describe('waitState', () => { + test('stays quiet at first, then says it is slow, then offers the practice run', () => { + expect(waitState(1000, 'account')).toEqual({ slow: false, canSkip: false }); + expect(waitState(SLOW_NOTE_AFTER_MS, 'opening')).toEqual({ slow: true, canSkip: false }); + expect(waitState(SKIP_OFFER_AFTER_MS, 'opening')).toEqual({ slow: true, canSkip: true }); + }); + + test('never offers a skip once the stairs are open or the practice notice shows', () => { + expect(waitState(60_000, 'ready')).toEqual({ slow: false, canSkip: false }); + expect(waitState(60_000, 'offline')).toEqual({ slow: false, canSkip: false }); + }); +}); + +describe('SkipGate', () => { + test('a skip wins over a slow chain job and its late result is ignored', async () => { + const gate = new SkipGate(); + let finish: (value: string) => void = () => undefined; + const job = new Promise((resolve) => { + finish = resolve; + }); + const raced = raceSkip(job, gate); + gate.skip(); + expect(await raced).toBe(SKIPPED); + expect(gate.skipped).toBe(true); + expect(gate.closed).toBe(true); + finish('late ticket'); + expect(await job).toBe('late ticket'); + }); + + test('a job that finishes first wins, and a later skip changes nothing', async () => { + const gate = new SkipGate(); + expect(await raceSkip(Promise.resolve('ticket'), gate)).toBe('ticket'); + gate.close(); + gate.skip(); + expect(gate.skipped).toBe(false); + }); + + test('a skipped wait leaves the chain queue ordered: the next job runs after the late one', async () => { + const queue = new ChainQueue(); + const order: string[] = []; + let release: () => void = () => undefined; + const gate = new SkipGate(); + const first = queue.enqueue(async () => { + await new Promise((resolve) => { + release = resolve; + }); + order.push('first'); + return 'first'; + }); + const raced = raceSkip(first, gate); + gate.skip(); + expect(await raced).toBe(SKIPPED); + const second = queue.enqueue(async () => { + order.push('second'); + }); + release(); + await second; + expect(order).toEqual(['first', 'second']); + }); + + test('a late failure after a skip is not an unhandled rejection', async () => { + const gate = new SkipGate(); + let fail: (err: Error) => void = () => undefined; + const job = new Promise((_, reject) => { + fail = reject; + }); + const raced = raceSkip(job, gate); + gate.skip(); + expect(await raced).toBe(SKIPPED); + fail(new Error('late')); + await expect(job).rejects.toThrow('late'); + }); +}); diff --git a/client/tests/stellar.test.ts b/client/tests/stellar.test.ts new file mode 100644 index 0000000..bdeba32 --- /dev/null +++ b/client/tests/stellar.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { RELIC_ASSETS, RELIC_ISSUER } from '../src/stellar/config'; +import { CONTRACT_ERROR, freshChoice, isContractError, veteranChoice } from '../src/stellar/choice'; +import { fetchRelicBalances } from '../src/stellar/relics'; +import { count, isAddress, isTxHash, summarize, utcDays, type PlayerMark } from '../src/stats/summary'; + +const ADDRESS = `G${'A'.repeat(55)}`; +const realFetch = globalThis.fetch; + +function respondWith(status: number, body: unknown): void { + globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch; +} + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe('fetchRelicBalances', () => { + const held = (code: string, balance: unknown, issuer: unknown = RELIC_ISSUER) => ({ asset_code: code, asset_issuer: issuer, balance }); + + test('reads whole relics of each kind from our issuer', async () => { + respondWith(200, { + balances: [ + held('DMCOMMON', '3.0000000'), + held('DMRARE', '1.0000000'), + held('DMCORE', '12.0000000'), + { asset_type: 'native', balance: '9.0000000' }, + ], + }); + expect(await fetchRelicBalances(ADDRESS)).toEqual({ common: 3, rare: 1, core: 12 }); + }); + + test('ignores a look alike asset from another issuer', async () => { + respondWith(200, { balances: [held('DMCORE', '500.0000000', `G${'B'.repeat(55)}`)] }); + expect(await fetchRelicBalances(ADDRESS)).toEqual({ common: 0, rare: 0, core: 0 }); + }); + + test('ignores malformed amounts and non string balances', async () => { + respondWith(200, { + balances: [held('DMCOMMON', '3'), held('DMRARE', 7), held('DMCORE', '1e9'), held('DMCOMMON', '99999999999999999.0000000')], + }); + expect(await fetchRelicBalances(ADDRESS)).toEqual({ common: 0, rare: 0, core: 0 }); + }); + + test('rounds a fractional balance down to whole relics', async () => { + respondWith(200, { balances: [held('DMRARE', '2.5000000')] }); + expect((await fetchRelicBalances(ADDRESS)).rare).toBe(2); + }); + + test('an unfunded account (404) holds nothing, other failures throw', async () => { + respondWith(404, {}); + expect(await fetchRelicBalances(ADDRESS)).toEqual({ common: 0, rare: 0, core: 0 }); + respondWith(500, {}); + await expect(fetchRelicBalances(ADDRESS)).rejects.toThrow('500'); + }); + + test('a body without a balances list is treated as empty', async () => { + respondWith(200, { balances: 'nope' }); + expect(await fetchRelicBalances(ADDRESS)).toEqual({ common: 0, rare: 0, core: 0 }); + }); + + test('a malformed address never reaches the network', async () => { + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response('{}'); + }) as unknown as typeof fetch; + expect(await fetchRelicBalances('../etc/passwd')).toEqual({ common: 0, rare: 0, core: 0 }); + expect(called).toBe(false); + }); +}); + +describe('contract helpers', () => { + test('recognises a contract error by its code only', () => { + const err = new Error('HostError: Error(Contract, #7)'); + expect(isContractError(err, CONTRACT_ERROR.TooFast)).toBe(true); + expect(isContractError(err, CONTRACT_ERROR.StartCooldown)).toBe(false); + expect(isContractError('Error(Contract, #7)', CONTRACT_ERROR.TooFast)).toBe(false); + }); + + test('builds tagged hero choices', () => { + const look = { name: 'Ash', emblem: 0, cloak: 0 }; + expect(freshChoice({ look, base: 0, weapon: 1, charm: 2 }).tag).toBe('Fresh'); + expect(veteranChoice(4)).toEqual({ tag: 'Veteran', values: [4] }); + }); + + test('the three relic assets are distinct classic assets with their own wrappers', () => { + const codes = Object.values(RELIC_ASSETS).map((asset) => asset.code); + const wrappers = Object.values(RELIC_ASSETS).map((asset) => asset.contractId); + expect(new Set(codes).size).toBe(3); + expect(new Set(wrappers).size).toBe(3); + expect(isAddress(RELIC_ISSUER)).toBe(true); + }); +}); + +describe('response validators', () => { + test('addresses and transaction hashes must match their exact shape', () => { + expect(isAddress(ADDRESS)).toBe(true); + expect(isAddress(`${ADDRESS}A`)).toBe(false); + expect(isAddress(`g${'a'.repeat(55)}`)).toBe(false); + expect(isAddress(42)).toBe(false); + expect(isTxHash('a'.repeat(64))).toBe(true); + expect(isTxHash('A'.repeat(64))).toBe(false); + expect(isTxHash('a'.repeat(63))).toBe(false); + }); + + test('count accepts small whole non negative numbers and bigints only', () => { + expect(count(5)).toBe(5); + expect(count(5n)).toBe(5); + expect(count(-1)).toBeNull(); + expect(count(1.5)).toBeNull(); + expect(count('5')).toBeNull(); + expect(count(4, 3)).toBeNull(); + expect(count(4n, 3)).toBeNull(); + expect(count(Number.NaN)).toBeNull(); + }); +}); + +describe('traction summary', () => { + const mark = (address: string, bestScore: number, bestFloor: number, relicsBanked = 0): PlayerMark => ({ + address, + bestScore, + bestFloor, + extractions: 1, + deaths: 1, + forfeits: 0, + relicsBanked, + finishedRuns: 2, + }); + + test('ranks players by score, then floor, and tallies floors and banked relics', () => { + const summary = summarize([mark('B', 10, 3, 2), mark('A', 10, 2), mark('C', 99, 1)], [], null, false, 5, false); + expect(summary.players.map((p) => p.address)).toEqual(['C', 'B', 'A']); + expect(summary.recordedRuns).toBe(6); + expect(summary.bestFloorThree).toBe(1); + expect(summary.bankedRelic).toBe(1); + expect(summary.floorCounts).toEqual([0, 1, 1, 1]); + expect(summary.openedCount).toBe(5); + }); + + test('counts recorded runs per day inside the window, newest runs first', () => { + const runs = [ + { at: '2026-10-01T10:00:00.000Z', address: 'A', floorReached: 3, score: 1, extracted: true, txHash: '' }, + { at: '2026-10-02T10:00:00.000Z', address: 'A', floorReached: 3, score: 1, extracted: true, txHash: '' }, + { at: '2026-10-02T11:00:00.000Z', address: 'B', floorReached: 1, score: 0, extracted: false, txHash: '' }, + ]; + const summary = summarize([], runs, { from: '2026-10-01T00:00:00.000Z', to: '2026-10-02T23:00:00.000Z' }, false, 0, false); + expect(summary.days).toEqual([ + { day: '2026-10-01', runs: 1 }, + { day: '2026-10-02', runs: 2 }, + ]); + expect(summary.recentRuns[0]?.at).toBe('2026-10-02T11:00:00.000Z'); + }); + + test('utcDays is inclusive, empty on a backwards window and capped to the latest days', () => { + expect(utcDays('2026-10-01T05:00:00Z', '2026-10-03T01:00:00Z')).toEqual(['2026-10-01', '2026-10-02', '2026-10-03']); + expect(utcDays('2026-10-03T00:00:00Z', '2026-10-01T00:00:00Z')).toEqual([]); + expect(utcDays('nonsense', '2026-10-01T00:00:00Z')).toEqual([]); + const capped = utcDays('2026-01-01T00:00:00Z', '2026-10-01T00:00:00Z', 5); + expect(capped).toHaveLength(5); + expect(capped[4]).toBe('2026-10-01'); + }); +}); diff --git a/client/tests/storage.test.ts b/client/tests/storage.test.ts new file mode 100644 index 0000000..09f5cde --- /dev/null +++ b/client/tests/storage.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { readItem, readJson, removeItem, writeItem, writeJson } from '../src/storage'; +import { installMemoryStorage } from './storage'; + +function installBrokenStorage(): void { + const fail = (): never => { + throw new Error('storage blocked'); + }; + (globalThis as { localStorage?: unknown }).localStorage = { getItem: fail, setItem: fail, removeItem: fail }; +} + +describe('safe storage with working storage', () => { + let store: Map; + beforeEach(() => { + store = installMemoryStorage(); + }); + + test('writes and reads raw strings', () => { + expect(writeItem('k', 'v')).toBe(true); + expect(readItem('k')).toBe('v'); + expect(store.get('k')).toBe('v'); + }); + + test('a missing key reads as null', () => { + expect(readItem('absent')).toBeNull(); + expect(readJson('absent')).toBeNull(); + }); + + test('round trips JSON', () => { + expect(writeJson('j', { a: 1, b: [2, 3] })).toBe(true); + expect(readJson('j')).toEqual({ a: 1, b: [2, 3] }); + }); + + test('corrupt JSON reads as null instead of throwing', () => { + store.set('bad', '{not json'); + expect(readJson('bad')).toBeNull(); + }); + + test('removes a key', () => { + writeItem('k', 'v'); + expect(removeItem('k')).toBe(true); + expect(readItem('k')).toBeNull(); + }); +}); + +describe('safe storage when storage throws', () => { + beforeEach(installBrokenStorage); + afterEach(() => { + delete (globalThis as { localStorage?: unknown }).localStorage; + }); + + test('reads give null and writes report failure', () => { + expect(readItem('k')).toBeNull(); + expect(readJson('k')).toBeNull(); + expect(writeItem('k', 'v')).toBe(false); + expect(writeJson('k', { a: 1 })).toBe(false); + expect(removeItem('k')).toBe(false); + }); + + test('a missing storage object is handled the same way', () => { + delete (globalThis as { localStorage?: unknown }).localStorage; + expect(readItem('k')).toBeNull(); + expect(writeItem('k', 'v')).toBe(false); + }); +}); diff --git a/client/tests/transient.test.ts b/client/tests/transient.test.ts new file mode 100644 index 0000000..80fd467 --- /dev/null +++ b/client/tests/transient.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test'; +import { isTransientError, retryTransient } from '../src/stellar/transient'; + +const noWait = async (): Promise => undefined; + +describe('isTransientError', () => { + test('recognises HTTP and network blips', () => { + for (const status of [429, 500, 502, 503, 504]) { + expect(isTransientError(new Error(`Request failed with status code ${status}`))).toBe(true); + expect(isTransientError({ response: { status } })).toBe(true); + } + expect(isTransientError(new Error('Friendbot funding failed with status 503'))).toBe(true); + expect(isTransientError(new TypeError('Failed to fetch'))).toBe(true); + expect(isTransientError(new Error('read ECONNRESET'))).toBe(true); + expect(isTransientError(new Error('Timed out waiting for Stellar'))).toBe(true); + }); + + test('leaves real refusals alone', () => { + expect(isTransientError({ response: { status: 400 } })).toBe(false); + expect(isTransientError(new Error('HostError: Error(Contract, #8)'))).toBe(false); + expect(isTransientError(new Error('Error(Contract, #504) refused'))).toBe(false); + expect(isTransientError(new Error('The account was not found'))).toBe(false); + expect(isTransientError(null)).toBe(false); + }); +}); + +describe('retryTransient', () => { + test('retries a transient failure and then succeeds', async () => { + let calls = 0; + const waits: number[] = []; + const value = await retryTransient( + async () => { + calls += 1; + if (calls < 3) throw new Error('Request failed with status code 502'); + return 'ok'; + }, + { sleep: async (ms) => void waits.push(ms), random: () => 0.5, backoffMs: 100, jitterMs: 100 } + ); + expect(value).toBe('ok'); + expect(calls).toBe(3); + expect(waits).toEqual([150, 250]); + }); + + test('gives up after the retry budget', async () => { + let calls = 0; + const attempt = retryTransient( + async () => { + calls += 1; + throw new Error('Request failed with status code 500'); + }, + { retries: 2, sleep: noWait } + ); + await expect(attempt).rejects.toThrow('500'); + expect(calls).toBe(3); + }); + + test('does not retry a permanent error or once cancelled', async () => { + let calls = 0; + await expect( + retryTransient( + async () => { + calls += 1; + throw new Error('Error(Contract, #8)'); + }, + { sleep: noWait } + ) + ).rejects.toThrow(); + expect(calls).toBe(1); + calls = 0; + await expect( + retryTransient( + async () => { + calls += 1; + throw new Error('Failed to fetch'); + }, + { sleep: noWait, cancelled: () => true } + ) + ).rejects.toThrow(); + expect(calls).toBe(1); + }); +}); + +describe('finish_run failures', () => { + test('a transient send error is retried and keeps the saved run', async () => { + const { failureKind, keepsRun } = await import('../src/app/failures'); + const blip = new Error('Request failed with status code 503'); + expect(failureKind(blip)).toBe('wait'); + expect(keepsRun(blip)).toBe(true); + }); +}); diff --git a/client/tests/txOutcome.test.ts b/client/tests/txOutcome.test.ts new file mode 100644 index 0000000..705eba6 --- /dev/null +++ b/client/tests/txOutcome.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; +import { isRetryableTxFailure, readSentResult, TxOutcomeError, type SentLike } from '../src/stellar/txOutcome'; + +/** Mimics the SDK, whose `result` getter parses `returnValue` without checking it. */ +function sentWith(response: SentLike['getTransactionResponse']): SentLike { + return { + getTransactionResponse: response, + get result(): string { + if (response && 'returnValue' in response) { + const value = response.returnValue as { type: string }; + return value.type; // throws "Cannot read properties of undefined (reading 'type')" + } + throw new Error('no result'); + }, + }; +} + +describe('readSentResult', () => { + test('returns the parsed result of a successful transaction', () => { + expect(readSentResult(sentWith({ status: 'SUCCESS', returnValue: { type: 'scvVec' } }))).toBe('scvVec'); + }); + + test('a failed transaction with an undefined return value is a retryable typed error, not a TypeError', () => { + const sent = sentWith({ status: 'FAILED', returnValue: undefined }); + expect(() => sent.result).toThrow(TypeError); + let caught: unknown; + try { + readSentResult(sent); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(TxOutcomeError); + expect(isRetryableTxFailure(caught)).toBe(true); + }); + + test('a success without a return value is a typed error that is not retried', () => { + let caught: unknown; + try { + readSentResult(sentWith({ status: 'SUCCESS', returnValue: undefined })); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(TxOutcomeError); + expect(isRetryableTxFailure(caught)).toBe(false); + }); + + test('other errors are never retryable', () => { + expect(isRetryableTxFailure(new Error('boom'))).toBe(false); + expect(isRetryableTxFailure(undefined)).toBe(false); + }); +}); diff --git a/client/tsconfig.json b/client/tsconfig.json index fd02ba0..6ee2f71 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -13,5 +13,5 @@ "isolatedModules": true, "esModuleInterop": true }, - "include": ["src", "tests"] + "include": ["src", "tests", "docs-site"] } diff --git a/client/vercel.json b/client/vercel.json new file mode 100644 index 0000000..8eb1756 --- /dev/null +++ b/client/vercel.json @@ -0,0 +1,17 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://i.ytimg.com; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://soroban-testnet.stellar.org https://horizon-testnet.stellar.org https://friendbot.stellar.org; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), payment=()" } + ] + } + ] +} diff --git a/client/vite.config.ts b/client/vite.config.ts index a99c83f..10548df 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig, type Plugin } from 'vite'; import { fileURLToPath } from 'node:url'; +import { docsSite } from './docs-site/plugin'; function statsShortcut(): Plugin { const rewrite = (url: string | undefined): string | undefined => { @@ -25,10 +26,41 @@ function statsShortcut(): Plugin { }; } +/** + * The Stellar SDK is the one large dependency (about 650 kB minified, 135 kB + * gzip). It is only ever reached through the dynamic import in + * `src/stellar/lazy.ts`, which warms it when the browser is idle, so it never + * sits on the path to the title. Naming it keeps it recognisable in the build + * output and out of the game's own chunks. + */ +const STELLAR_SDK_CHUNK = 'stellar-sdk'; +/** + * Vite warns above this size, in kB. It sits just over the lazy SDK chunk so + * the warning still fires if the SDK grows or if a heavy module lands in an + * eager chunk. The tracked limit is the cold load transfer budget in + * `scripts/budgets.ts`, documented in `docs/performance.md`. + */ +const CHUNK_WARNING_KB = 700; + +/** + * Vite's dynamic import helper is shared by every entry. Left to the SDK's + * chunk assignment it would land inside it and make the title preload the SDK, + * so it gets its own tiny chunk. + */ +function splitChunks(id: string): string | undefined { + if (id.includes('vite/preload-helper')) return 'preload-helper'; + if (id.includes('/node_modules/@stellar/')) return STELLAR_SDK_CHUNK; + return undefined; +} + export default defineConfig({ - plugins: [statsShortcut()], + plugins: [statsShortcut(), docsSite()], build: { + chunkSizeWarningLimit: CHUNK_WARNING_KB, rollupOptions: { + output: { + manualChunks: splitChunks, + }, input: { main: fileURLToPath(new URL('./index.html', import.meta.url)), stats: fileURLToPath(new URL('./stats/index.html', import.meta.url)), diff --git a/contracts/deepmark-contract/README.md b/contracts/deepmark-contract/README.md index 691ea45..98667ee 100644 --- a/contracts/deepmark-contract/README.md +++ b/contracts/deepmark-contract/README.md @@ -1,5 +1,7 @@ # deepmark-contract +Live on Stellar testnet as [`CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX`](https://stellar.expert/explorer/testnet/contract/CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX), admin [`GCJWADMY...2WEX`](https://stellar.expert/explorer/testnet/account/GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX). The relic contracts are [`DMCOMMON`](https://stellar.expert/explorer/testnet/contract/CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R), [`DMRARE`](https://stellar.expert/explorer/testnet/contract/CCQFEYZR3D4XHTMLV3XJZBTZUT4WXZS2LUXHPFTH7DRRZENZNU2NVZ5L) and [`DMCORE`](https://stellar.expert/explorer/testnet/contract/CAHKK75BOXGBKSXBB3EFPWS6C3IZIXIFLYI3A74GYCRSI4U6NKVZTLIM), with the issuer [`GAEZFWDU...FA7Q`](https://stellar.expert/explorer/testnet/account/GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q). The deployed wasm is 28,107 bytes, sha256 `db721cb97242bf5d9710d70752959a5658c2be81fee70d5fdd7253b878f80d07`. To replay a real run yourself, see "Verify a run in 60 seconds" in the main README. + Verified runs and relic assets for Deepmark, on Stellar/Soroban. The contract replays every run itself and never trusts a claimed result. Scaffolded with `cougr new --template starter` for the crate layout, `wasm32v1-none` build and test conventions. The generated entity-component demo was replaced: the data model is one record per player address, which does not fit an ECS built for many anonymous entities, so `cougr-core` is not a dependency. See `docs/architecture/onchain-boundary.md` in the main repo for the full reasoning. @@ -10,7 +12,7 @@ Scaffolded with `cougr new --template starter` for the crate layout, `wasm32v1-n | --- | --- | --- | --- | | `__constructor` | `admin`, `relic_common`, `relic_rare`, `relic_core`: `Address` | none | Sets the admin and the three relic Stellar Asset Contracts once. The admin must sign the deployment. | | `start_run` | `player: Address`, `choice: HeroChoice` | `Result` | Opens a run, issues its seed on chain and fixes the delver: `Fresh(look, base, weapon, charm)` or `Veteran(hero_id)` that the player owns. Starting while a run is open forfeits it. Cooldown between starts. Requires the player's signature. | -| `finish_run` | `player: Address`, `actions: Bytes` | `Result` | Replays the action log, derives the result, updates the profile and the right leaderboard, mints banked relics, and after a full clear earns the delver (fresh) or adds a victory (veteran). Consumes the open run. Requires the player's signature. | +| `finish_run` | `player: Address`, `actions: Bytes` | `Result` | Replays the action log, derives the result, updates the profile and the right leaderboard, mints banked relics, and after a full clear earns the delver (fresh) or adds a victory (veteran). Consumes the open run. If a relic mint fails (for example no trustline for that relic) the call fails with `MintFailed` and nothing is recorded, so the run stays open and can be finished again once the cause is fixed. Requires the player's signature. | | `verify_run` | `base`, `weapon`, `charm`, `rank`, `seed`: `u32`, `actions: Bytes` | `Result` | Pure replay with no writes. Anyone can audit a run. | | `get_record` | `player: Address` | `Option` | A player's profile. | | `get_open_run` | `player: Address` | `Option` | The run a player has open. | @@ -18,7 +20,7 @@ Scaffolded with `cougr new --template starter` for the crate layout, `wasm32v1-n | `top_veteran` | none | `Vec` | The same for veterans. | | `player_count` | none | `u32` | Distinct players that ever opened a run. Not capped. | | `players` | `start: u32`, `limit: u32` | `Vec` | Profiles in first-seen order, at most 50 per call. | -| `relic_tokens` | none | `RelicTokens` | The three relic asset contracts. | +| `relic_tokens` | none | `Result` | The three relic asset contracts, or `NotInitialized` if they are missing from storage. | | `admin` | none | `Option
    ` | Who can upgrade the code, or `None` once the role was renounced. | | `version` | none | `u32` | The contract release, bumped whenever behavior changes. | | `upgrade` | `new_wasm_hash: BytesN<32>` | `Result<(), Error>` | Replaces the contract code with an uploaded wasm. Storage is kept. Admin only. Emits `upgraded`. | @@ -29,9 +31,9 @@ Scaffolded with `cougr new --template starter` for the crate layout, `wasm32v1-n | `heroes_of` | `owner: Address` | `Vec` | Every delver an address owns, at most 64. | | `balance` | `owner: Address` | `u32` | How many delvers an address owns. | | `hero_count` | none | `u32` | How many delvers have ever been earned. | -| `transfer` | `from`, `to: Address`, `id: u32` | `Result<(), Error>` | Give a delver away. Requires the owner's signature. | +| `transfer` | `from`, `to: Address`, `id: u32` | `Result<(), Error>` | Give a delver away. Requires the owner's signature. Giving a delver to its current owner succeeds and changes nothing (no event). | -Errors: `InvalidHero` 1, `RunNotStarted` 2, `TooManyActions` 3, `InvalidAction` 4, `RunIncomplete` 5, `TrailingActions` 6, `TooFast` 7, `StartCooldown` 8, `InvalidName` 9, `HeroNotFound` 10, `NotHeroOwner` 11, `Frozen` 12 (the admin role was renounced). +Errors: `InvalidHero` 1, `RunNotStarted` 2, `TooManyActions` 3, `InvalidAction` 4, `RunIncomplete` 5, `TrailingActions` 6, `TooFast` 7, `StartCooldown` 8, `InvalidName` 9, `HeroNotFound` 10, `NotHeroOwner` 11, `Frozen` 12 (the admin role was renounced), `NotInitialized` 13 (relic tokens missing), `MintFailed` 14 (a relic mint was rejected, the run stays open). ## Delvers and ownership @@ -53,7 +55,7 @@ lib.rs entrypoints: require_auth, replay, persist, mint, emit events world.rs delvers, enemies and the three floors sim.rs deterministic replay of a run, pure Rust, no Env systems.rs ranking key and profile update, pure Rust, no Env -tests/golden.rs replays 400 runs played by the real TypeScript game +tests/golden.rs replays the 600 runs in golden.txt, played by the real TypeScript game ``` ## Trust model @@ -62,18 +64,18 @@ tests/golden.rs replays 400 runs played by the real TypeScript game - **Seed.** Issued on chain from ledger data, the player's run counter and address. - **Replay protection.** Finishing consumes the open run. - **Authorization.** Both writing entrypoints require the player's own signature. -- **Relics.** Minted through Stellar Asset Contracts whose admin is this contract. A release deployment locks the issuer account (master weight zero, no other signer), which `deploy-testnet.sh --lock` verifies on Horizon, including that the issuer's own key can no longer sign. A mint uses `try_mint`, so a player without a trustline never loses a verified result. +- **Relics.** Minted through Stellar Asset Contracts whose admin is this contract. The current testnet deployment locked the issuer account (master weight zero, no other signer) in [one transaction](https://stellar.expert/explorer/testnet/tx/ddae4f0c5f28ff1bc08af97949dd85861fe3b087eefb67b1e4893b354c514e28), and `docs/deployments.md` records the check on Horizon. There is no deploy script in this repository: the steps are the transactions listed there. A mint failure (a SAC rejects it when the player has no trustline for the relic, or it is full or deauthorized) fails the whole `finish_run` and reverts its writes, so the run stays open and no relic is silently dropped. Players need the three relic trustlines before extracting with relics. - **Admin and upgrade path.** The deployer is the admin and can replace the code with `upgrade`. This is a real trust assumption: an admin could upgrade to code that mints relics without a replay, rewrites the leaderboard or moves delvers. Replay verification protects players from forged logs, not from the admin while the role exists. The role is meant to be temporary: - `renounce_admin` removes it for good, after which `upgrade` and `set_admin` fail with `Frozen` and the deployed code is final. - Before a mainnet release, either renounce, or `set_admin` to a multisig account so no single key can change the code. - `set_admin` needs both signatures so the role cannot be lost to a mistyped address. - Storage layout is not versioned: an upgrade must keep every `DataKey` and stored struct readable. `version()` tells a client which release runs. -Not covered: seed rerolling is limited by a cooldown and a visible abandoned run counter, not eliminated, and a solver can still play a legal perfect run. There is no third party audit. Review that did happen: 71 unit and entrypoint tests (including the admin paths), 600 cross-language vectors with random kits, a mutation check that the vectors catch RNG drift, and live testnet attempts to replay a log, forge a floor 3 run and restart too soon, all rejected. +Not covered: seed rerolling is limited by a cooldown and a visible abandoned run counter, both per address, so many throwaway addresses defeat them. A solver can play a legal perfect run, relics have no supply cap, and persistent entries that nobody touches for about 120 days can still be archived (the counters, leaderboards, a used veteran and its owner index are renewed by `start_run`, but the records and index entries of players who stop playing are not). `docs/threat-model.md` has each of these. There is no third party audit. Review that did happen: 80 unit and entrypoint tests (including the admin paths), 600 cross-language vectors with random kits, a mutation check that the vectors catch RNG drift, and live testnet attempts to replay a log, forge a floor 3 run and restart too soon, all rejected. ## Storage -Persistent storage for profiles, the player index and the top list, each with its TTL extended on write. Instance storage holds the admin and the relic tokens, and its TTL is extended on every `start_run` and `finish_run`, which keeps the contract instance and its code alive while the game is played. The open run lives in temporary storage and expires on its own after two days. The number of players is not capped because each player is an independent entry. +Persistent storage for profiles, the player index and the top list, each with its TTL extended on write. `start_run` also renews the player counter, the delver counter, both leaderboards and, for a veteran run, that delver and its owner index; these renewals cost nothing while the entries have more than 30 days left. `verify_run` and the getters never write. Instance storage holds the admin and the relic tokens, and its TTL is extended on every `start_run` and `finish_run`, which keeps the contract instance and its code alive while the game is played. The open run lives in temporary storage and expires on its own after two days. The number of players is not capped because each player is an independent entry. ## Build and test diff --git a/contracts/deepmark-contract/src/lib.rs b/contracts/deepmark-contract/src/lib.rs index 263023a..aca6833 100644 --- a/contracts/deepmark-contract/src/lib.rs +++ b/contracts/deepmark-contract/src/lib.rs @@ -25,13 +25,15 @@ pub mod sim; pub mod systems; #[cfg(test)] mod test; +#[cfg(test)] +mod test_safety; +pub mod ttl; pub mod world; use components::{ DataKey, FreshHero, Hero, HeroChoice, Look, PlayerRecord, PlayerSummary, RelicTokens, RunOutcome, RunTicket, TopEntry, ACTIONS_PER_LEDGER, CONTRACT_VERSION, MAX_ACTIONS, MAX_HEROES_PER_OWNER, MAX_PAGE, RELIC_UNIT, RUN_TTL, START_COOLDOWN_LEDGERS, TOP_SIZE, - TTL_EXTEND_TO, TTL_THRESHOLD, }; use sim::{Ending, Outcome, SimError}; use soroban_sdk::{ @@ -39,6 +41,7 @@ use soroban_sdk::{ Address, Bytes, BytesN, Env, String, Vec, }; use systems::{apply_outcome, rank_key}; +use ttl::{extend_instance, keep_hero_alive, keep_shared_alive, save_persistent}; use world::{Loadout, MAX_FLOOR, MAX_RANK}; #[contracterror] @@ -58,6 +61,12 @@ pub enum Error { NotHeroOwner = 11, /// The admin role was renounced, so the contract can no longer change. Frozen = 12, + /// The relic token addresses are missing from storage. + NotInitialized = 13, + /// A relic mint failed (for example the player has no trustline for that + /// relic). Nothing is recorded and the run stays open, so the player can + /// fix the cause and call `finish_run` again. + MintFailed = 14, } impl From for Error { @@ -152,6 +161,7 @@ impl DeepmarkContract { pub fn start_run(env: Env, player: Address, choice: HeroChoice) -> Result { player.require_auth(); extend_instance(&env); + keep_shared_alive(&env); let (loadout, look, veteran) = match choice { HeroChoice::Fresh(fresh) => { @@ -163,6 +173,7 @@ impl DeepmarkContract { if hero.owner != player { return Err(Error::NotHeroOwner); } + keep_hero_alive(&env, id, &player); let rank = hero.clears.min(MAX_RANK); let loadout = Loadout { base: hero.base, @@ -295,11 +306,16 @@ impl DeepmarkContract { None }; - let minted = outcome.ending == Ending::Extracted && mint_relics(&env, &player, &outcome); + // A failed mint returns an error, which reverts every write above and + // leaves the run open. `minted` is therefore true for every extraction. + let extracted = outcome.ending == Ending::Extracted; + if extracted { + mint_relics(&env, &player, &outcome)?; + } RunFinished { player, - extracted: outcome.ending == Ending::Extracted, + extracted, floor_reached: outcome.floor_reached, score: outcome.score, relics_common: outcome.relics_common, @@ -308,7 +324,7 @@ impl DeepmarkContract { hp_left: outcome.hp_left, actions: outcome.actions, is_new_best, - minted, + minted: extracted, hero_id, } .publish(&env); @@ -428,8 +444,8 @@ impl DeepmarkContract { } /// The Stellar Asset Contracts that hold relics. - pub fn relic_tokens(env: Env) -> RelicTokens { - env.storage().instance().get(&DataKey::Relics).unwrap() + pub fn relic_tokens(env: Env) -> Result { + load_relics(&env) } /// A delver by id, or `None`. @@ -476,6 +492,12 @@ impl DeepmarkContract { if hero.owner != from { return Err(Error::NotHeroOwner); } + // Giving a delver to its own owner changes nothing: succeed without + // touching storage or emitting an event, so the owner's list never + // gets a duplicate id. + if from == to { + return Ok(()); + } let mut theirs: Vec = env .storage() .persistent() @@ -513,14 +535,6 @@ impl DeepmarkContract { } } -/// Keep the contract instance (admin, relic tokens, and with it the code) alive -/// while the game is being played. -fn extend_instance(env: &Env) { - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); -} - fn name_is_valid(name: &String) -> bool { let len = name.len() as usize; if !(world::NAME_MIN..=world::NAME_MAX).contains(&len) { @@ -573,17 +587,6 @@ fn derive_seed(env: &Env, player: &Address, runs: u32) -> u32 { u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]) } -fn save_persistent>( - env: &Env, - key: &DataKey, - value: &V, -) { - env.storage().persistent().set(key, value); - env.storage() - .persistent() - .extend_ttl(key, TTL_THRESHOLD, TTL_EXTEND_TO); -} - fn save_record(env: &Env, key: &DataKey, record: &PlayerRecord) { save_persistent(env, key, record); } @@ -708,12 +711,19 @@ fn earn_or_advance( Some(id) } -/// Mint the relics a verified extraction banked. Uses `try_mint` so a player -/// without a trustline never loses their verified result: the run is recorded -/// either way and `minted` reports whether every mint succeeded. -fn mint_relics(env: &Env, player: &Address, outcome: &Outcome) -> bool { - let tokens: RelicTokens = env.storage().instance().get(&DataKey::Relics).unwrap(); - let mut all_minted = true; +fn load_relics(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Relics) + .ok_or(Error::NotInitialized) +} + +/// Mint the relics a verified extraction banked. A failed mint (the SAC +/// rejects it when the player has no trustline for that relic, or the +/// trustline is full or deauthorized) is a real, fixable condition, so it +/// fails the whole `finish_run` instead of silently dropping the relics. +fn mint_relics(env: &Env, player: &Address, outcome: &Outcome) -> Result<(), Error> { + let tokens = load_relics(env)?; for (token, amount) in [ (&tokens.common, outcome.relics_common), (&tokens.rare, outcome.relics_rare), @@ -725,10 +735,10 @@ fn mint_relics(env: &Env, player: &Address, outcome: &Outcome) -> bool { let result = StellarAssetClient::new(env, token).try_mint(player, &(amount as i128 * RELIC_UNIT)); if !matches!(result, Ok(Ok(()))) { - all_minted = false; + return Err(Error::MintFailed); } } - all_minted + Ok(()) } fn to_contract_outcome(outcome: &Outcome, key: u64, hero_id: Option) -> RunOutcome { diff --git a/contracts/deepmark-contract/src/test.rs b/contracts/deepmark-contract/src/test.rs index 29e0adc..2b42923 100644 --- a/contracts/deepmark-contract/src/test.rs +++ b/contracts/deepmark-contract/src/test.rs @@ -18,22 +18,22 @@ use soroban_sdk::{ }; use std::{vec, vec::Vec}; -struct Fixture<'a> { - env: Env, - client: DeepmarkContractClient<'a>, +pub struct Fixture<'a> { + pub env: Env, + pub client: DeepmarkContractClient<'a>, common: Address, rare: Address, core: Address, } -fn set_ledger(env: &Env, sequence: u32) { +pub fn set_ledger(env: &Env, sequence: u32) { env.ledger().with_mut(|l| { l.sequence_number = sequence; l.timestamp = 1_700_000_000 + sequence as u64 * 5; }); } -fn setup<'a>() -> Fixture<'a> { +pub fn setup<'a>() -> Fixture<'a> { let env = Env::default(); env.mock_all_auths(); set_ledger(&env, 100); @@ -83,11 +83,11 @@ fn fresh(env: &Env, base: u32, weapon: u32, charm: u32, name: &str) -> HeroChoic } /// Bulwark Pike and Stone Heart on Lantern: 12 HP, damage 2 to 4. -fn plain(env: &Env) -> HeroChoice { +pub fn plain(env: &Env) -> HeroChoice { fresh(env, 0, 2, 2, "Ash") } -fn bytes(env: &Env, actions: &[u8]) -> Bytes { +pub fn bytes(env: &Env, actions: &[u8]) -> Bytes { Bytes::from_slice(env, actions) } @@ -97,7 +97,7 @@ fn repeat(action: u8, times: usize) -> Vec { /// Floor 1 without a fight: over the detour to the stairs, then extract. /// It touches no RNG, so it is valid for any issued seed. -fn free_extraction() -> Vec { +pub fn free_extraction() -> Vec { let mut a = Vec::new(); a.extend(repeat(ACTION_UP, 3)); a.extend(repeat(ACTION_RIGHT, 7)); @@ -110,7 +110,7 @@ fn free_extraction() -> Vec { /// Beat the floor 1 Warden, take the common relic from the pocket, walk out /// over the detour and extract. Extra attacks after the kill are ignored by /// the simulation, so this is valid for any seed and either delver. -fn relic_extraction() -> Vec { +pub fn relic_extraction() -> Vec { let mut a = repeat(ACTION_RIGHT, 4); a.extend(repeat(ACTION_ATTACK, 3)); a.extend(repeat(ACTION_RIGHT, 2)); @@ -184,14 +184,14 @@ fn try_clear(loadout: &Loadout, seed: u32) -> Option> { (outcome.floor_reached == 3 && outcome.ending == crate::sim::Ending::Extracted).then_some(log) } -fn finish_ledger(f: &Fixture, actions: usize) { +pub fn finish_ledger(f: &Fixture, actions: usize) { let now = f.env.ledger().sequence(); set_ledger(&f.env, now + (actions as u32 / 16) + 1); } /// Opens runs, one after another, until the issued seed lets the scripted /// player clear all three floors, then finishes that run. Returns the outcome. -fn clear_all_floors( +pub fn clear_all_floors( f: &Fixture, player: &Address, choice: &HeroChoice, @@ -210,7 +210,7 @@ fn clear_all_floors( } /// A strong, sturdy fresh delver the scripted player can clear with often. -fn strong(env: &Env, name: &str) -> (HeroChoice, Loadout) { +pub fn strong(env: &Env, name: &str) -> (HeroChoice, Loadout) { ( fresh(env, 1, 1, 0, name), Loadout { @@ -483,41 +483,6 @@ fn a_banked_relic_is_minted_as_a_stellar_asset() { assert_eq!(f.client.get_record(&player).unwrap().relics_banked, 1); } -#[test] -fn a_failed_mint_never_loses_the_verified_run() { - // Relic addresses that are not tokens make every mint fail, which is what - // a player with no trustline looks like. The run must still be recorded. - let env = Env::default(); - env.mock_all_auths(); - set_ledger(&env, 100); - let bogus = [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]; - let id = env.register( - DeepmarkContract, - ( - Address::generate(&env), - bogus[0].clone(), - bogus[1].clone(), - bogus[2].clone(), - ), - ); - let client = DeepmarkContractClient::new(&env, &id); - let player = Address::generate(&env); - - client.start_run(&player, &plain(&env)); - let log = relic_extraction(); - set_ledger(&env, 100 + log.len() as u32 / 16 + 1); - let outcome = client.finish_run(&player, &bytes(&env, &log)); - - assert_eq!(outcome.relics_common, 1); - let record = client.get_record(&player).unwrap(); - assert_eq!(record.extractions, 1); - assert_eq!(client.top_fresh().len(), 1); -} - #[test] fn a_run_without_relics_mints_nothing() { let f = setup(); diff --git a/contracts/deepmark-contract/src/test_safety.rs b/contracts/deepmark-contract/src/test_safety.rs new file mode 100644 index 0000000..0228835 --- /dev/null +++ b/contracts/deepmark-contract/src/test_safety.rs @@ -0,0 +1,231 @@ +//! Tests for the audited weaknesses: self transfers, a missing relic +//! configuration, failed mints, and storage lifetime. + +use crate::components::{DataKey, HeroChoice, DAY_IN_LEDGERS, TTL_EXTEND_TO, TTL_THRESHOLD}; +use crate::test::{ + bytes, clear_all_floors, finish_ledger, free_extraction, plain, relic_extraction, set_ledger, + setup, strong, +}; +use crate::{DeepmarkContract, DeepmarkContractClient, Error}; +use soroban_sdk::{ + testutils::{storage::Persistent as _, Address as _}, + Address, Env, +}; + +fn ttl_of(f: &crate::test::Fixture, key: &DataKey) -> u32 { + f.env.as_contract(&f.client.address, || { + f.env.storage().persistent().get_ttl(key) + }) +} + +#[test] +fn giving_a_delver_to_its_own_owner_changes_nothing() { + let f = setup(); + let owner = Address::generate(&f.env); + let (choice, solver) = strong(&f.env, "Mara Voss"); + let id = clear_all_floors(&f, &owner, &choice, &solver) + .hero_id + .unwrap(); + + f.client.transfer(&owner, &owner, &id); + f.client.transfer(&owner, &owner, &id); + + assert_eq!(f.client.balance(&owner), 1); + assert_eq!(f.client.heroes_of(&owner).len(), 1); + assert_eq!(f.client.heroes_of(&owner).get(0), Some(id)); + assert_eq!(f.client.owner_of(&id), Some(owner)); + assert_eq!(f.client.hero_count(), 1); +} + +#[test] +fn a_self_transfer_still_needs_the_owner() { + let f = setup(); + let owner = Address::generate(&f.env); + let other = Address::generate(&f.env); + let (choice, solver) = strong(&f.env, "Mara Voss"); + let id = clear_all_floors(&f, &owner, &choice, &solver) + .hero_id + .unwrap(); + + assert_eq!( + f.client.try_transfer(&other, &other, &id), + Err(Ok(Error::NotHeroOwner)) + ); + assert_eq!( + f.client.try_transfer(&owner, &owner, &999), + Err(Ok(Error::HeroNotFound)) + ); + assert_eq!(f.client.balance(&other), 0); + assert_eq!(f.client.balance(&owner), 1); +} + +#[test] +fn transfers_keep_every_delver_in_exactly_one_list() { + let f = setup(); + let first = Address::generate(&f.env); + let second = Address::generate(&f.env); + let (choice, solver) = strong(&f.env, "Mara Voss"); + let id = clear_all_floors(&f, &first, &choice, &solver) + .hero_id + .unwrap(); + + f.client.transfer(&first, &second, &id); + f.client.transfer(&second, &second, &id); + f.client.transfer(&second, &first, &id); + f.client.transfer(&first, &second, &id); + + assert_eq!(f.client.heroes_of(&first).len(), 0); + assert_eq!(f.client.heroes_of(&second).len(), 1); + assert_eq!(f.client.balance(&first) + f.client.balance(&second), 1); + assert_eq!(f.client.owner_of(&id), Some(second)); +} + +#[test] +fn a_missing_relic_configuration_is_a_typed_error() { + let f = setup(); + let player = Address::generate(&f.env); + f.client.start_run(&player, &plain(&f.env)); + f.env.as_contract(&f.client.address, || { + f.env.storage().instance().remove(&DataKey::Relics); + }); + + assert_eq!(f.client.try_relic_tokens(), Err(Ok(Error::NotInitialized))); + + let log = relic_extraction(); + finish_ledger(&f, log.len()); + assert_eq!( + f.client.try_finish_run(&player, &bytes(&f.env, &log)), + Err(Ok(Error::NotInitialized)) + ); + assert!(f.client.get_open_run(&player).is_some()); +} + +/// A contract whose relic addresses are not tokens, so every mint fails the +/// way a mint to a player without a trustline does. +fn failing_mint_setup<'a>(env: &'a Env) -> DeepmarkContractClient<'a> { + let id = env.register( + DeepmarkContract, + ( + Address::generate(env), + Address::generate(env), + Address::generate(env), + Address::generate(env), + ), + ); + DeepmarkContractClient::new(env, &id) +} + +#[test] +fn a_failed_mint_fails_the_finish_and_keeps_the_run_open() { + let env = Env::default(); + env.mock_all_auths(); + set_ledger(&env, 100); + let client = failing_mint_setup(&env); + let player = Address::generate(&env); + + let ticket = client.start_run(&player, &plain(&env)); + let log = relic_extraction(); + set_ledger(&env, 100 + log.len() as u32 / 16 + 1); + assert_eq!( + client.try_finish_run(&player, &bytes(&env, &log)), + Err(Ok(Error::MintFailed)) + ); + + // Nothing was recorded and the very same run can still be finished. + assert_eq!(client.get_open_run(&player), Some(ticket)); + let record = client.get_record(&player).unwrap(); + assert_eq!(record.extractions, 0); + assert_eq!(client.top_fresh().len(), 0); +} + +#[test] +fn an_extraction_without_relics_needs_no_mint() { + let env = Env::default(); + env.mock_all_auths(); + set_ledger(&env, 100); + let client = failing_mint_setup(&env); + let player = Address::generate(&env); + + client.start_run(&player, &plain(&env)); + let log = free_extraction(); + set_ledger(&env, 100 + log.len() as u32 / 16 + 1); + let outcome = client.finish_run(&player, &bytes(&env, &log)); + + assert!(outcome.extracted); + assert_eq!(client.get_record(&player).unwrap().extractions, 1); + assert!(client.get_open_run(&player).is_none()); +} + +#[test] +fn starting_a_run_keeps_the_shared_entries_alive() { + let f = setup(); + let player = Address::generate(&f.env); + f.client.start_run(&player, &plain(&f.env)); + let log = free_extraction(); + finish_ledger(&f, log.len()); + f.client.finish_run(&player, &bytes(&f.env, &log)); + + // 100 days idle: still alive, but under the renewal threshold. + let idle = f.env.ledger().sequence() + 100 * DAY_IN_LEDGERS; + set_ledger(&f.env, idle); + for key in [ + DataKey::PlayerCount, + DataKey::TopFresh, + DataKey::PlayerAt(0), + ] { + assert!(ttl_of(&f, &key) < TTL_THRESHOLD); + } + + f.client.start_run(&player, &plain(&f.env)); + + for key in [DataKey::PlayerCount, DataKey::TopFresh] { + assert_eq!(ttl_of(&f, &key), TTL_EXTEND_TO); + } + assert_eq!(f.client.player_count(), 1); + assert_eq!(f.client.top_fresh().len(), 1); +} + +#[test] +fn using_a_veteran_keeps_it_and_its_owner_index_alive() { + let f = setup(); + let owner = Address::generate(&f.env); + let (choice, solver) = strong(&f.env, "Mara Voss"); + let id = clear_all_floors(&f, &owner, &choice, &solver) + .hero_id + .unwrap(); + + set_ledger(&f.env, f.env.ledger().sequence() + 100 * DAY_IN_LEDGERS); + assert!(ttl_of(&f, &DataKey::Hero(id)) < TTL_THRESHOLD); + + f.client.start_run(&owner, &HeroChoice::Veteran(id)); + + assert_eq!(ttl_of(&f, &DataKey::Hero(id)), TTL_EXTEND_TO); + assert_eq!(ttl_of(&f, &DataKey::HeroesOf(owner.clone())), TTL_EXTEND_TO); + assert_eq!(f.client.owner_of(&id), Some(owner)); +} + +#[test] +fn keeping_entries_alive_costs_no_rent_while_they_are_fresh() { + let f = setup(); + let player = Address::generate(&f.env); + f.client.start_run(&player, &plain(&f.env)); + set_ledger(&f.env, f.env.ledger().sequence() + 10); + f.client.start_run(&player, &plain(&f.env)); + + let resources = f.env.cost_estimate().resources(); + assert_eq!(resources.persistent_entry_rent_bumps, 0); +} + +#[test] +fn verify_run_never_writes() { + let f = setup(); + let log = free_extraction(); + let outcome = f + .client + .verify_run(&0, &2, &2, &0, &1234, &bytes(&f.env, &log)); + + assert!(outcome.extracted); + let resources = f.env.cost_estimate().resources(); + assert_eq!(resources.write_entries, 0); + assert_eq!(resources.persistent_entry_rent_bumps, 0); +} diff --git a/contracts/deepmark-contract/src/ttl.rs b/contracts/deepmark-contract/src/ttl.rs new file mode 100644 index 0000000..413df1b --- /dev/null +++ b/contracts/deepmark-contract/src/ttl.rs @@ -0,0 +1,59 @@ +//! Storage lifetime. Persistent entries are archived when their TTL runs out, +//! so every call that already writes also keeps the shared and the touched +//! entries alive. `extend_ttl` with a threshold is a no-op (and costs no rent) +//! while an entry still has more than `TTL_THRESHOLD` ledgers left. + +use crate::components::{DataKey, TTL_EXTEND_TO, TTL_THRESHOLD}; +use soroban_sdk::{Address, Env}; + +/// Entries every player depends on: the player counter, the delver counter and +/// both leaderboards. They only change on rare events, so without this they +/// could idle past their TTL. +const SHARED_KEYS: [DataKey; 4] = [ + DataKey::PlayerCount, + DataKey::HeroCount, + DataKey::TopFresh, + DataKey::TopVeteran, +]; + +/// Keep the contract instance (admin, relic tokens, and with it the code) alive +/// while the game is being played. +pub fn extend_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); +} + +/// Extend an existing persistent entry. Missing entries are skipped because +/// `extend_ttl` would trap on them. +pub fn keep_alive(env: &Env, key: &DataKey) { + let storage = env.storage().persistent(); + if storage.has(key) { + storage.extend_ttl(key, TTL_THRESHOLD, TTL_EXTEND_TO); + } +} + +/// Keep the counters and leaderboards alive. Called by `start_run`. +pub fn keep_shared_alive(env: &Env) { + for key in SHARED_KEYS.iter() { + keep_alive(env, key); + } +} + +/// Keep a veteran and its owner's index alive when it is used for a run. +pub fn keep_hero_alive(env: &Env, id: u32, owner: &Address) { + keep_alive(env, &DataKey::Hero(id)); + keep_alive(env, &DataKey::HeroesOf(owner.clone())); +} + +/// Write a persistent value and extend its lifetime. +pub fn save_persistent>( + env: &Env, + key: &DataKey, + value: &V, +) { + env.storage().persistent().set(key, value); + env.storage() + .persistent() + .extend_ttl(key, TTL_THRESHOLD, TTL_EXTEND_TO); +} diff --git a/diagrams/.gitignore b/diagrams/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/diagrams/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/diagrams/bun.lock b/diagrams/bun.lock new file mode 100644 index 0000000..4a188c5 --- /dev/null +++ b/diagrams/bun.lock @@ -0,0 +1,56 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "deepmark-diagrams", + "dependencies": { + "@resvg/resvg-js": "2.6.2", + "@viz-js/viz": "3.25.0", + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "typescript": "^5.6.0", + }, + }, + }, + "packages": { + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "@viz-js/viz": ["@viz-js/viz@3.25.0", "", {}, "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/diagrams/out/png/01-system-context.png b/diagrams/out/png/01-system-context.png new file mode 100644 index 0000000..8c5f50a Binary files /dev/null and b/diagrams/out/png/01-system-context.png differ diff --git a/diagrams/out/png/02-run-sequence.png b/diagrams/out/png/02-run-sequence.png new file mode 100644 index 0000000..5119922 Binary files /dev/null and b/diagrams/out/png/02-run-sequence.png differ diff --git a/diagrams/out/png/03-replay-verification.png b/diagrams/out/png/03-replay-verification.png new file mode 100644 index 0000000..6696dc4 Binary files /dev/null and b/diagrams/out/png/03-replay-verification.png differ diff --git a/diagrams/out/png/04-relic-trust.png b/diagrams/out/png/04-relic-trust.png new file mode 100644 index 0000000..de379ea Binary files /dev/null and b/diagrams/out/png/04-relic-trust.png differ diff --git a/diagrams/out/png/05-client-architecture.png b/diagrams/out/png/05-client-architecture.png new file mode 100644 index 0000000..23ae97b Binary files /dev/null and b/diagrams/out/png/05-client-architecture.png differ diff --git a/diagrams/out/png/06-data-model.png b/diagrams/out/png/06-data-model.png new file mode 100644 index 0000000..923babd Binary files /dev/null and b/diagrams/out/png/06-data-model.png differ diff --git a/diagrams/out/png/07-deployment-map.png b/diagrams/out/png/07-deployment-map.png new file mode 100644 index 0000000..44c38b1 Binary files /dev/null and b/diagrams/out/png/07-deployment-map.png differ diff --git a/diagrams/out/svg/01-system-context.svg b/diagrams/out/svg/01-system-context.svg new file mode 100644 index 0000000..2a273db --- /dev/null +++ b/diagrams/out/svg/01-system-context.svg @@ -0,0 +1,153 @@ + + + + +Deepmark system contextThe browser game plays every run locally and talks to the Stellar testnet only to start and finish a run. The Deepmark contract replays the run and mints relics through three Stellar Asset Contracts whose issuer account is locked. + +G + + +cluster_browser + +Browser (untrusted) + + +cluster_services + +Stellar testnet services + + +cluster_chain + +On chain + + + +game + +Canvas game +render, audio, input +never waits on a transaction + + + +sim + +Simulation and action log +TypeScript, client/src/game +seeded, one byte per action + + + + +key + +Keypair +generated in the browser +kept in localStorage + + + + +rpc + +Soroban RPC +submits transactions +simulates and reads + + + +sim->rpc + + + +start_run, then +finish_run(actions) + + + +friendbot + +Friendbot +funds a new account once + + + +key->friendbot + + + +fund once + + + + +contract + +Deepmark contract +start_run, finish_run, verify_run +replays every run itself +profiles, leaderboards, delvers + + + +rpc->contract + + + +invoke + + + +horizon + +Horizon +relic balances (read only) + + + +horizon->game + + + +balances + + + + +sac + +Relic SACs +DMCOMMON, DMRARE, DMCORE +admin: the Deepmark contract + + + +contract->sac + + + +try_mint after a +verified extraction + + + +issuer + +Issuer account +locked: master weight 0 +no other signer, nobody can mint + + + +sac->issuer + + + +assets issued by + + + diff --git a/diagrams/out/svg/02-run-sequence.svg b/diagrams/out/svg/02-run-sequence.svg new file mode 100644 index 0000000..1d7f1d4 --- /dev/null +++ b/diagrams/out/svg/02-run-sequence.svg @@ -0,0 +1,57 @@ +One run, two transactionsSequence of one Deepmark run. start_run issues the seed on chain while the descent animation plays. The player then plays locally with no transactions. finish_run sends the action log, the contract replays it, updates the profile and leaderboard and mints relics. A rejected mint reverts the whole finish_run and leaves the run open. + +Playerin the browser +Game clientTypeScript, plays the run locally +Deepmark contractSoroban +Relic SACsDMCOMMON, DMRARE, DMCORE + +Transaction 1: start_run (while the descent animation plays) +forge a delver, press descend + + +start_run(player, choice) +player signs + + + +validate the delver +derive the seed on chain +store the open run +RunTicket (seed, delver) +event run_started + + + +Play locally (no transaction, no waiting) +move, attack, retreat, descend, extract + + + +simulation runs in the browser with the issued seed +one byte per action is appended to the action log +the result screen shows at once + +Transaction 2: finish_run (after the result screen is showing) +finish_run(player, actions) +player signs + + + +replay the log with the Rust port +derive floor, score, relics, HP +consume the open run +update profile and leaderboard +mint(player, amount) +only after an extraction +rejected: MintFailed, run stays open + + +RunOutcome +event run_finished +hero_earned after a first full clear + + + +If Stellar is unreachable the game plays a labelled practice run. +A failed or slow finish_run never blocks play. + \ No newline at end of file diff --git a/diagrams/out/svg/03-replay-verification.svg b/diagrams/out/svg/03-replay-verification.svg new file mode 100644 index 0000000..99f1198 --- /dev/null +++ b/diagrams/out/svg/03-replay-verification.svg @@ -0,0 +1,202 @@ + + + + +Replay verification and trust modelThe contract never trusts a claimed result. It takes the issued run ticket and the action log, rejects malformed logs, replays the run with a Rust port of the TypeScript game and derives the result itself. 600 golden vectors played by the TypeScript game keep the two implementations identical in cargo test. + +G + + +cluster_inputs + +Inputs + + +cluster_replay + +finish_run on chain + + +cluster_lane + +Keeping Rust and TypeScript identical + + + +ticket + +Run ticket +issued by start_run +seed, base, weapon, charm, rank + + + +guards + +Guards +player signed, open run exists +TooFast: at least actions / 16 ledgers since start +veteran still owned by the player + + + +ticket->guards + + + + + +actions + +Action log +sent by the client +one byte per action, at most 2048 + + + +actions->guards + + + + + +replay + +sim::replay +Mulberry32 rng, three fixed floors, +enemies and delver stats (Rust port) +applies the actions one by one + + + +guards->replay + + + + + +checks + +Log checks +InvalidAction: unknown byte +RunIncomplete: log ends before extraction or death +TrailingActions: actions after the end + + + +replay->checks + + + + + +verify + +verify_run +same replay, no writes +anyone can audit a run: +delver, seed from run_started, log + + + +replay->verify + + + +same code + + + +result + +Derived result +floor, score, relics, HP left, rank key +nothing is read from the client + + + +checks->result + + + + + +rejected + +Rejected +the transaction fails with a contract error +nothing is written, the open run is kept + + + +checks->rejected + + + +any guard or check fails + + + +legit + +Legitimate run +consumes the open run (no second claim) +profile, leaderboard, delver, relics + + + +result->legit + + + + + +ts + +TypeScript game +client/src/game + + + +golden + +600 golden vectors +bun run golden +tests/golden.txt + + + +ts->golden + + + +plays + + + +cargo + +cargo test +replays every vector in Rust +any mismatch fails the build + + + +golden->cargo + + + + + +cargo->replay + + + +proves it +matches + + + diff --git a/diagrams/out/svg/04-relic-trust.svg b/diagrams/out/svg/04-relic-trust.svg new file mode 100644 index 0000000..1bf2cef --- /dev/null +++ b/diagrams/out/svg/04-relic-trust.svg @@ -0,0 +1,148 @@ + + + + +Relic and delver minting and the trust modelOnly a replay verified run can mint. finish_run mints through three Stellar Asset Contracts whose admin is the Deepmark contract, and the issuer account is locked. If a mint is rejected, for example because the player has no trustline, finish_run fails with MintFailed and reverts, so the run stays open and can be finished again. A full clear by a fresh delver also earns an owned delver record. The one remaining trust assumption is the contract admin who can upgrade the code until the role is renounced or moved to a multisig. + +G + + +cluster_mint + +Minting path (only inside finish_run) + + +cluster_assets + +Relic assets + + +cluster_control + +Control plane + + + +verified + +Verified run +replay accepted +open run consumed + + + +relics + +Banked relics +only when the run extracted +counts come from the replay + + + +verified->relics + + + +extracted + + + +hero + +Delver earned +fresh delver, extracted from floor 3 +veteran: one more victory, rank up to 3 +stored in the contract, not a standard NFT + + + +verified->hero + + + +full clear + + + +sac + +Three Stellar Asset Contracts +DMCOMMON, DMRARE, DMCORE +admin: the Deepmark contract + + + +relics->sac + + + +mint +(rejected: MintFailed, +whole finish reverts) + + + +issuer + +Issuer account +master weight 0, no other signer +nobody holds a key that can mint + + + +sac->issuer + + + +issued by + + + +player + +Player account +holds relics through a trustline +no trustline: the mint fails, +finish_run reverts (MintFailed) +the run stays open to retry + + + +sac->player + + + +one whole unit +per relic + + + +admin + +Contract admin (the deployer) +upgrade, set_admin, renounce_admin +trust assumption: could ship code that skips the replay +planned: renounce or multisig before mainnet + + + +frozen + +Frozen +admin() is None +upgrade and set_admin fail +the deployed code is final + + + +admin->frozen + + + +renounce_admin + + + diff --git a/diagrams/out/svg/05-client-architecture.svg b/diagrams/out/svg/05-client-architecture.svg new file mode 100644 index 0000000..4bb6332 --- /dev/null +++ b/diagrams/out/svg/05-client-architecture.svg @@ -0,0 +1,161 @@ + + + + +Client architectureThe TypeScript client has a game layer with the deterministic simulation, a render and audio layer, an input layer and a stellar layer. render and stellar depend on game and game imports from neither. The Stellar SDK chunk loads lazily when the browser is idle. A separate stats page reads the chain. + +G + + +cluster_chain + +client/src/stellar + + + +entry + +main.ts and game-app.ts +wires input, game, render and stellar +one game loop, no router + + + +input + +input/ +keyboard.ts: keys to actions + + + +entry->input + + + + + +render + +render/ +canvas, camera, particles, motion +title, creator, descent, result +audio.ts: sound + + + +entry->render + + + + + +game + +game/ (the simulation) +state, combat, floor, enemies, heroes, items +rng.ts: Mulberry32, mirrored in Rust +progress, savedRun, draft: localStorage +imports nothing from render, input or stellar + + + +entry->game + + + +applies actions + + + +lazy + +lazy.ts +loads the SDK chunk when idle +the title paints without it + + + +entry->lazy + + + +warms when idle + + + +render->game + + + +draws state + + + +cfg + +config.ts and relics.ts +network urls, contract and relic ids +relic balances from Horizon + + + +render->cfg + + + +ids and types only + + + +sdk + +api.ts: account.ts, contract.ts +keypair in localStorage, Friendbot, trustlines +start_run, finish_run and reads through RPC +the lazily loaded Stellar SDK chunk + + + +lazy->sdk + + + +dynamic import + + + +sdk->cfg + + + + + +traction + +traction.ts +reads contract events and players + + + +traction->cfg + + + + + +stats + +stats/ (second page) +traction page, separate entry + + + +stats->traction + + + + + diff --git a/diagrams/out/svg/06-data-model.svg b/diagrams/out/svg/06-data-model.svg new file mode 100644 index 0000000..efada00 --- /dev/null +++ b/diagrams/out/svg/06-data-model.svg @@ -0,0 +1,143 @@ + + + + +Contract data modelWhat the Deepmark contract stores. Persistent storage holds player records, the player index, two leaderboards of the best 100 runs and the earned delvers. start_run also renews the player counter, the delver counter, both leaderboards and the veteran delver it uses. Temporary storage holds one open run ticket per player that expires after two days. Instance storage holds the admin and the relic token addresses. + +G + + +cluster_persistent + +Persistent storage: TTL extended on every write (to 120 days when under 30), counters, boards and a veteran renewed by start_run + + +cluster_temporary + +Temporary storage: expires on its own + + +cluster_instance + +Instance storage: TTL extended on start_run and finish_run, and at deploy + + + +rec + +PlayerRecord +Record(Address), one per player +best_key, best_floor, best_score +best_hp_left, best_actions +total_runs, extractions, deaths +forfeits, relics_banked +last_start_ledger +vet_best_*, veteran_runs + + + +index + +Player index +PlayerCount, PlayerAt(n) +n -> player address +paged by players(start, limit) +at most 50 per call, no cap + + + +index->rec + + + +address + + + +top + +TopFresh and TopVeteran +Vec<TopEntry>, best 100 +one row per player +player, rank_key, floor, score +hp_left, actions, name, emblem + + + +top->rec + + + +player + + + +hero + +Hero +Hero(id), HeroCount +id, owner, look +base, weapon, charm +clears (rank) +proof_score, proof_hp_left, proof_actions +minted_ledger + + + +owned + +HeroesOf +HeroesOf(Address) +delver ids per owner +at most 64 per owner + + + +owned->hero + + + +ids + + + +run + +RunTicket +Run(Address), 2 days +seed, start_ledger +base, weapon, charm, rank +look, veteran: Option<id> +removed by finish_run + + + +run->hero + + + +veteran id + + + +admin + +Admin +Admin +Option<Address> +removed by renounce_admin + + + +tokens + +RelicTokens +Relics, set once in the constructor +common, rare, core +Stellar Asset Contract addresses + + + diff --git a/diagrams/out/svg/07-deployment-map.svg b/diagrams/out/svg/07-deployment-map.svg new file mode 100644 index 0000000..74c86f4 --- /dev/null +++ b/diagrams/out/svg/07-deployment-map.svg @@ -0,0 +1,134 @@ + + + + +Deployment and verification mapThe testnet deployment: the Deepmark contract, three relic Stellar Asset Contracts that it administers, the issuer account that is locked, and the deployer admin account. Each node can be checked on the explorer or with the Stellar CLI. + +G + + + +admin + +Deployer and admin account +can upgrade until it renounces +check: admin() on the contract + + + +game + +Deepmark contract +one instance on testnet +check: version(), relic_tokens(), admin() +check: wasm hash vs stellar contract fetch +instance and wasm TTL extended at deploy + + + +admin->game + + + +upgrade, set_admin, +renounce_admin + + + +events + +Contract events +run_started, run_finished, hero_earned +verify_run replays any run from them + + + +game->events + + + +emits + + + +common + +DMCOMMON SAC +admin: Deepmark contract + + + +game->common + + + +mint + + + +rare + +DMRARE SAC +admin: Deepmark contract + + + +game->rare + + + +mint + + + +core + +DMCORE SAC +admin: Deepmark contract + + + +game->core + + + +mint + + + +issuer + +Relic issuer account (locked) +master weight 0, no other signer +its own key can no longer sign +check: the account on the explorer or Horizon + + + +common->issuer + + + +issued by + + + +rare->issuer + + + +issued by + + + +core->issuer + + + +issued by + + + diff --git a/diagrams/package.json b/diagrams/package.json new file mode 100644 index 0000000..21e6cca --- /dev/null +++ b/diagrams/package.json @@ -0,0 +1,20 @@ +{ + "name": "deepmark-diagrams", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "bun@1.3.14", + "scripts": { + "build": "bun run src/build.ts", + "check": "bun run src/check.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "typescript": "^5.6.0" + }, + "dependencies": { + "@resvg/resvg-js": "2.6.2", + "@viz-js/viz": "3.25.0" + } +} diff --git a/diagrams/src/build.ts b/diagrams/src/build.ts new file mode 100644 index 0000000..294f9ec --- /dev/null +++ b/diagrams/src/build.ts @@ -0,0 +1,21 @@ +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { svgToPng } from './render'; +import { ALL_DIAGRAMS } from './registry'; + +/** Writes every diagram as svg and png into `/svg` and `/png`. */ +export async function buildAll(outDir: string, withPng: boolean): Promise { + await mkdir(join(outDir, 'svg'), { recursive: true }); + if (withPng) await mkdir(join(outDir, 'png'), { recursive: true }); + for (const diagram of ALL_DIAGRAMS) { + const svg = await diagram.svg(); + await Bun.write(join(outDir, 'svg', `${diagram.meta.name}.svg`), svg); + if (withPng) await Bun.write(join(outDir, 'png', `${diagram.meta.name}.png`), svgToPng(svg)); + } +} + +if (import.meta.main) { + const out = join(import.meta.dir, '..', 'out'); + await buildAll(out, true); + console.log(`Wrote ${ALL_DIAGRAMS.length} diagrams to ${out}`); +} diff --git a/diagrams/src/check.ts b/diagrams/src/check.ts new file mode 100644 index 0000000..52cc009 --- /dev/null +++ b/diagrams/src/check.ts @@ -0,0 +1,36 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildAll } from './build'; + +/** + * Regenerates the svg files into a temp dir and fails when they differ from the + * committed ones. PNGs are not compared: font rasterisation varies by machine. + */ +const committed = join(import.meta.dir, '..', 'out'); +const temp = await mkdtemp(join(tmpdir(), 'deepmark-diagrams-')); +let failed = false; +try { + await buildAll(temp, false); + const fresh = (await readdir(join(temp, 'svg'))).sort(); + const existing = (await readdir(join(committed, 'svg')).catch(() => [] as string[])).sort(); + if (fresh.join() !== existing.join()) { + console.error(`Diagram set differs. Generated: ${fresh.join(', ')}. Committed: ${existing.join(', ')}.`); + failed = true; + } + for (const name of fresh) { + const next = await Bun.file(join(temp, 'svg', name)).text(); + const old = await Bun.file(join(committed, 'svg', name)).text().catch(() => ''); + if (next !== old) { + console.error(`Out of date: out/svg/${name}`); + failed = true; + } + } +} finally { + await rm(temp, { recursive: true, force: true }); +} +if (failed) { + console.error('Run `bun run build` in diagrams/ and commit the result.'); + process.exit(1); +} +console.log('Diagrams are up to date.'); diff --git a/diagrams/src/diagram.ts b/diagrams/src/diagram.ts new file mode 100644 index 0000000..030efcd --- /dev/null +++ b/diagrams/src/diagram.ts @@ -0,0 +1,12 @@ +import { dotToSvg, withAccessibility } from './render'; +import type { DiagramMeta } from './style'; + +export interface Diagram { + meta: DiagramMeta; + /** Returns the finished svg, accessibility metadata included. */ + svg: () => Promise; +} + +export function dotDiagram(meta: DiagramMeta, dot: () => string): Diagram { + return { meta, svg: async () => withAccessibility(await dotToSvg(dot()), meta) }; +} diff --git a/diagrams/src/diagrams/01-system-context.ts b/diagrams/src/diagrams/01-system-context.ts new file mode 100644 index 0000000..07d48ed --- /dev/null +++ b/diagrams/src/diagrams/01-system-context.ts @@ -0,0 +1,40 @@ +import { dotDiagram } from '../diagram'; +import { cluster, edge, graph, node } from '../style'; + +export default dotDiagram( + { + name: '01-system-context', + title: 'Deepmark system context', + description: + 'The browser game plays every run locally and talks to the Stellar testnet only to start and finish a run. The Deepmark contract replays the run and mints relics through three Stellar Asset Contracts whose issuer account is locked.', + }, + () => + graph('LR', [ + cluster('browser', 'Browser (untrusted)', 'client', [ + node({ id: 'game', title: 'Canvas game', lines: ['render, audio, input', 'never waits on a transaction'], zone: 'client' }), + node({ id: 'sim', title: 'Simulation and action log', lines: ['TypeScript, client/src/game', 'seeded, one byte per action'], zone: 'client' }), + node({ id: 'key', title: 'Keypair', lines: ['generated in the browser', 'kept in localStorage'], zone: 'client' }), + '{ rank=same; game; sim; key; }', + 'game -> sim -> key [style=invis];', + ]), + cluster('services', 'Stellar testnet services', 'network', [ + node({ id: 'friendbot', title: 'Friendbot', lines: ['funds a new account once'], zone: 'network' }), + node({ id: 'rpc', title: 'Soroban RPC', lines: ['submits transactions', 'simulates and reads'], zone: 'network' }), + node({ id: 'horizon', title: 'Horizon', lines: ['relic balances (read only)'], zone: 'network' }), + '{ rank=same; friendbot; rpc; horizon; }', + 'horizon -> rpc -> friendbot [style=invis];', + ]), + cluster('chain', 'On chain', 'contract', [ + node({ id: 'contract', title: 'Deepmark contract', lines: ['start_run, finish_run, verify_run', 'replays every run itself', 'profiles, leaderboards, delvers'], zone: 'contract' }), + node({ id: 'sac', title: 'Relic SACs', lines: ['DMCOMMON, DMRARE, DMCORE', 'admin: the Deepmark contract'], zone: 'asset' }), + node({ id: 'issuer', title: 'Issuer account', lines: ['locked: master weight 0', 'no other signer, nobody can mint'], zone: 'asset' }), + '{ rank=same; sac; issuer; }', + ]), + edge({ from: 'key', to: 'friendbot', text: 'fund once', zone: 'network' }), + edge({ from: 'sim', to: 'rpc', text: 'start_run, then\nfinish_run(actions)', zone: 'client' }), + edge({ from: 'horizon', to: 'game', text: 'balances', zone: 'network', dashed: true, extra: 'constraint=false' }), + edge({ from: 'rpc', to: 'contract', text: 'invoke', zone: 'network' }), + edge({ from: 'contract', to: 'sac', text: 'try_mint after a\nverified extraction', zone: 'contract' }), + edge({ from: 'sac', to: 'issuer', text: 'assets issued by', zone: 'asset', dashed: true }), + ]), +); diff --git a/diagrams/src/diagrams/02-run-sequence.ts b/diagrams/src/diagrams/02-run-sequence.ts new file mode 100644 index 0000000..51c1a38 --- /dev/null +++ b/diagrams/src/diagrams/02-run-sequence.ts @@ -0,0 +1,40 @@ +import type { Diagram } from '../diagram'; +import type { DiagramMeta } from '../style'; +import { withAccessibility } from '../render'; +import { renderSequence, type Lifeline, type Step } from '../sequence'; + +const LIFELINES: readonly Lifeline[] = [ + { id: 'player', title: 'Player', subtitle: 'in the browser', zone: 'client' }, + { id: 'client', title: 'Game client', subtitle: 'TypeScript, plays the run locally', zone: 'client' }, + { id: 'contract', title: 'Deepmark contract', subtitle: 'Soroban', zone: 'contract' }, + { id: 'sac', title: 'Relic SACs', subtitle: 'DMCOMMON, DMRARE, DMCORE', zone: 'asset' }, +]; + +const STEPS: readonly Step[] = [ + { kind: 'phase', text: 'Transaction 1: start_run (while the descent animation plays)' }, + { kind: 'arrow', from: 'player', to: 'client', lines: ['forge a delver, press descend'] }, + { kind: 'arrow', from: 'client', to: 'contract', lines: ['start_run(player, choice)', 'player signs'], zone: 'contract' }, + { kind: 'note', over: ['contract'], lines: ['validate the delver', 'derive the seed on chain', 'store the open run'], zone: 'contract' }, + { kind: 'arrow', from: 'contract', to: 'client', lines: ['RunTicket (seed, delver)', 'event run_started'], zone: 'contract', dashed: true }, + { kind: 'phase', text: 'Play locally (no transaction, no waiting)' }, + { kind: 'arrow', from: 'player', to: 'client', lines: ['move, attack, retreat, descend, extract'] }, + { kind: 'note', over: ['player', 'client'], lines: ['simulation runs in the browser with the issued seed', 'one byte per action is appended to the action log', 'the result screen shows at once'], zone: 'client' }, + { kind: 'phase', text: 'Transaction 2: finish_run (after the result screen is showing)' }, + { kind: 'arrow', from: 'client', to: 'contract', lines: ['finish_run(player, actions)', 'player signs'], zone: 'contract' }, + { kind: 'note', over: ['contract'], lines: ['replay the log with the Rust port', 'derive floor, score, relics, HP', 'consume the open run', 'update profile and leaderboard'], zone: 'contract' }, + { kind: 'arrow', from: 'contract', to: 'sac', lines: ['mint(player, amount)', 'only after an extraction', 'rejected: MintFailed, run stays open'], zone: 'asset' }, + { kind: 'arrow', from: 'contract', to: 'client', lines: ['RunOutcome', 'event run_finished', 'hero_earned after a first full clear'], zone: 'contract', dashed: true }, + { kind: 'note', over: ['player', 'client'], lines: ['If Stellar is unreachable the game plays a labelled practice run.', 'A failed or slow finish_run never blocks play.'], zone: 'warn' }, +]; + +const META: DiagramMeta = { + name: '02-run-sequence', + title: 'One run, two transactions', + description: + 'Sequence of one Deepmark run. start_run issues the seed on chain while the descent animation plays. The player then plays locally with no transactions. finish_run sends the action log, the contract replays it, updates the profile and leaderboard and mints relics. A rejected mint reverts the whole finish_run and leaves the run open.', +}; + +export default { + meta: META, + svg: async () => withAccessibility(renderSequence(LIFELINES, STEPS), META), +} satisfies Diagram; diff --git a/diagrams/src/diagrams/03-replay-verification.ts b/diagrams/src/diagrams/03-replay-verification.ts new file mode 100644 index 0000000..a2846d5 --- /dev/null +++ b/diagrams/src/diagrams/03-replay-verification.ts @@ -0,0 +1,44 @@ +import { dotDiagram } from '../diagram'; +import { cluster, edge, graph, node } from '../style'; + +export default dotDiagram( + { + name: '03-replay-verification', + title: 'Replay verification and trust model', + description: + 'The contract never trusts a claimed result. It takes the issued run ticket and the action log, rejects malformed logs, replays the run with a Rust port of the TypeScript game and derives the result itself. 600 golden vectors played by the TypeScript game keep the two implementations identical in cargo test.', + }, + () => + graph('TB', [ + cluster('inputs', 'Inputs', 'client', [ + node({ id: 'ticket', title: 'Run ticket', lines: ['issued by start_run', 'seed, base, weapon, charm, rank'], zone: 'contract' }), + node({ id: 'actions', title: 'Action log', lines: ['sent by the client', 'one byte per action, at most 2048'], zone: 'client' }), + '{ rank=same; ticket; actions; }', + ]), + cluster('replay', 'finish_run on chain', 'contract', [ + node({ id: 'guards', title: 'Guards', lines: ['player signed, open run exists', 'TooFast: at least actions / 16 ledgers since start', 'veteran still owned by the player'], zone: 'contract' }), + node({ id: 'replay', title: 'sim::replay', lines: ['Mulberry32 rng, three fixed floors,', 'enemies and delver stats (Rust port)', 'applies the actions one by one'], zone: 'contract' }), + node({ id: 'checks', title: 'Log checks', lines: ['InvalidAction: unknown byte', 'RunIncomplete: log ends before extraction or death', 'TrailingActions: actions after the end'], zone: 'contract' }), + node({ id: 'result', title: 'Derived result', lines: ['floor, score, relics, HP left, rank key', 'nothing is read from the client'], zone: 'ok', shape: 'box' }), + ]), + cluster('lane', 'Keeping Rust and TypeScript identical', 'client', [ + node({ id: 'ts', title: 'TypeScript game', lines: ['client/src/game'], zone: 'client' }), + node({ id: 'golden', title: '600 golden vectors', lines: ['bun run golden', 'tests/golden.txt'], zone: 'client' }), + node({ id: 'cargo', title: 'cargo test', lines: ['replays every vector in Rust', 'any mismatch fails the build'], zone: 'contract' }), + ]), + node({ id: 'verify', title: 'verify_run', lines: ['same replay, no writes', 'anyone can audit a run:', 'delver, seed from run_started, log'], zone: 'network' }), + node({ id: 'rejected', title: 'Rejected', lines: ['the transaction fails with a contract error', 'nothing is written, the open run is kept'], zone: 'danger' }), + node({ id: 'legit', title: 'Legitimate run', lines: ['consumes the open run (no second claim)', 'profile, leaderboard, delver, relics'], zone: 'ok' }), + edge({ from: 'ticket', to: 'guards' }), + edge({ from: 'actions', to: 'guards' }), + edge({ from: 'guards', to: 'replay' }), + edge({ from: 'replay', to: 'checks' }), + edge({ from: 'checks', to: 'result' }), + edge({ from: 'result', to: 'legit', zone: 'ok' }), + edge({ from: 'checks', to: 'rejected', text: 'any guard or check fails', zone: 'danger', dashed: true, extra: 'constraint=false' }), + edge({ from: 'replay', to: 'verify', text: 'same code', zone: 'network', dashed: true }), + edge({ from: 'ts', to: 'golden', text: 'plays', zone: 'client' }), + edge({ from: 'golden', to: 'cargo', zone: 'client' }), + edge({ from: 'cargo', to: 'replay', text: 'proves it\nmatches', zone: 'contract', dashed: true, extra: 'constraint=false' }), + ]), +); diff --git a/diagrams/src/diagrams/04-relic-trust.ts b/diagrams/src/diagrams/04-relic-trust.ts new file mode 100644 index 0000000..3fb7aed --- /dev/null +++ b/diagrams/src/diagrams/04-relic-trust.ts @@ -0,0 +1,34 @@ +import { dotDiagram } from '../diagram'; +import { cluster, edge, graph, node } from '../style'; + +export default dotDiagram( + { + name: '04-relic-trust', + title: 'Relic and delver minting and the trust model', + description: + 'Only a replay verified run can mint. finish_run mints through three Stellar Asset Contracts whose admin is the Deepmark contract, and the issuer account is locked. If a mint is rejected, for example because the player has no trustline, finish_run fails with MintFailed and reverts, so the run stays open and can be finished again. A full clear by a fresh delver also earns an owned delver record. The one remaining trust assumption is the contract admin who can upgrade the code until the role is renounced or moved to a multisig.', + }, + () => + graph('LR', [ + cluster('mint', 'Minting path (only inside finish_run)', 'contract', [ + node({ id: 'verified', title: 'Verified run', lines: ['replay accepted', 'open run consumed'], zone: 'ok' }), + node({ id: 'relics', title: 'Banked relics', lines: ['only when the run extracted', 'counts come from the replay'], zone: 'contract' }), + node({ id: 'hero', title: 'Delver earned', lines: ['fresh delver, extracted from floor 3', 'veteran: one more victory, rank up to 3', 'stored in the contract, not a standard NFT'], zone: 'contract' }), + ]), + cluster('assets', 'Relic assets', 'asset', [ + node({ id: 'sac', title: 'Three Stellar Asset Contracts', lines: ['DMCOMMON, DMRARE, DMCORE', 'admin: the Deepmark contract'], zone: 'asset' }), + node({ id: 'issuer', title: 'Issuer account', lines: ['master weight 0, no other signer', 'nobody holds a key that can mint'], zone: 'asset' }), + ]), + node({ id: 'player', title: 'Player account', lines: ['holds relics through a trustline', 'no trustline: the mint fails,', 'finish_run reverts (MintFailed)', 'the run stays open to retry'], zone: 'client' }), + cluster('control', 'Control plane', 'warn', [ + node({ id: 'admin', title: 'Contract admin (the deployer)', lines: ['upgrade, set_admin, renounce_admin', 'trust assumption: could ship code that skips the replay', 'planned: renounce or multisig before mainnet'], zone: 'warn' }), + node({ id: 'frozen', title: 'Frozen', lines: ['admin() is None', 'upgrade and set_admin fail', 'the deployed code is final'], zone: 'ok' }), + ]), + edge({ from: 'verified', to: 'relics', text: 'extracted', zone: 'ok' }), + edge({ from: 'verified', to: 'hero', text: 'full clear', zone: 'ok' }), + edge({ from: 'relics', to: 'sac', text: 'mint\n(rejected: MintFailed,\nwhole finish reverts)', zone: 'contract' }), + edge({ from: 'sac', to: 'player', text: 'one whole unit\nper relic', zone: 'asset' }), + edge({ from: 'sac', to: 'issuer', text: 'issued by', zone: 'asset', dashed: true }), + edge({ from: 'admin', to: 'frozen', text: 'renounce_admin', zone: 'ok' }), + ]), +); diff --git a/diagrams/src/diagrams/05-client-architecture.ts b/diagrams/src/diagrams/05-client-architecture.ts new file mode 100644 index 0000000..422136e --- /dev/null +++ b/diagrams/src/diagrams/05-client-architecture.ts @@ -0,0 +1,35 @@ +import { dotDiagram } from '../diagram'; +import { cluster, edge, graph, node } from '../style'; + +export default dotDiagram( + { + name: '05-client-architecture', + title: 'Client architecture', + description: + 'The TypeScript client has a game layer with the deterministic simulation, a render and audio layer, an input layer and a stellar layer. render and stellar depend on game and game imports from neither. The Stellar SDK chunk loads lazily when the browser is idle. A separate stats page reads the chain.', + }, + () => + graph('TB', [ + node({ id: 'entry', title: 'main.ts and game-app.ts', lines: ['wires input, game, render and stellar', 'one game loop, no router'], zone: 'client' }), + node({ id: 'input', title: 'input/', lines: ['keyboard.ts: keys to actions'], zone: 'client' }), + node({ id: 'render', title: 'render/', lines: ['canvas, camera, particles, motion', 'title, creator, descent, result', 'audio.ts: sound'], zone: 'client' }), + node({ id: 'game', title: 'game/ (the simulation)', lines: ['state, combat, floor, enemies, heroes, items', 'rng.ts: Mulberry32, mirrored in Rust', 'progress, savedRun, draft: localStorage', 'imports nothing from render, input or stellar'], zone: 'contract' }), + cluster('chain', 'client/src/stellar', 'asset', [ + node({ id: 'lazy', title: 'lazy.ts', lines: ['loads the SDK chunk when idle', 'the title paints without it'], zone: 'asset' }), + node({ id: 'sdk', title: 'api.ts: account.ts, contract.ts', lines: ['keypair in localStorage, Friendbot, trustlines', 'start_run, finish_run and reads through RPC', 'the lazily loaded Stellar SDK chunk'], zone: 'asset' }), + node({ id: 'traction', title: 'traction.ts', lines: ['reads contract events and players'], zone: 'asset' }), + node({ id: 'cfg', title: 'config.ts and relics.ts', lines: ['network urls, contract and relic ids', 'relic balances from Horizon'], zone: 'asset' }), + ]), + node({ id: 'stats', title: 'stats/ (second page)', lines: ['traction page, separate entry'], zone: 'network' }), + edge({ from: 'entry', to: 'input', zone: 'client' }), + edge({ from: 'entry', to: 'render', zone: 'client' }), + edge({ from: 'entry', to: 'lazy', text: 'warms when idle', zone: 'asset' }), + edge({ from: 'render', to: 'game', text: 'draws state', zone: 'contract' }), + edge({ from: 'entry', to: 'game', text: 'applies actions', zone: 'contract', extra: 'constraint=false' }), + edge({ from: 'lazy', to: 'sdk', text: 'dynamic import', zone: 'asset' }), + edge({ from: 'sdk', to: 'cfg', zone: 'asset' }), + edge({ from: 'traction', to: 'cfg', zone: 'asset' }), + edge({ from: 'stats', to: 'traction', zone: 'network' }), + edge({ from: 'render', to: 'cfg', text: 'ids and types only', zone: 'asset', dashed: true, extra: 'constraint=false' }), + ]), +); diff --git a/diagrams/src/diagrams/06-data-model.ts b/diagrams/src/diagrams/06-data-model.ts new file mode 100644 index 0000000..c0e8635 --- /dev/null +++ b/diagrams/src/diagrams/06-data-model.ts @@ -0,0 +1,44 @@ +import { dotDiagram } from '../diagram'; +import { ZONES, cluster, edge, graph, recordLabel, type Zone } from '../style'; + +function record(id: string, title: string, note: string, fields: readonly string[], zone: Zone): string { + const style = ZONES[zone]; + return `${id} [label=${recordLabel(title, fields, note, zone)}, shape=box, style="rounded,filled", fillcolor="${style.fill}", color="${style.line}"];`; +} + +export default dotDiagram( + { + name: '06-data-model', + title: 'Contract data model', + description: + 'What the Deepmark contract stores. Persistent storage holds player records, the player index, two leaderboards of the best 100 runs and the earned delvers. start_run also renews the player counter, the delver counter, both leaderboards and the veteran delver it uses. Temporary storage holds one open run ticket per player that expires after two days. Instance storage holds the admin and the relic token addresses.', + }, + () => + graph('LR', [ + cluster('persistent', 'Persistent storage: TTL extended on every write (to 120 days when under 30), counters, boards and a veteran renewed by start_run', 'contract', [ + record('rec', 'PlayerRecord', 'Record(Address), one per player', [ + 'best_key, best_floor, best_score', + 'best_hp_left, best_actions', + 'total_runs, extractions, deaths', + 'forfeits, relics_banked', + 'last_start_ledger', + 'vet_best_*, veteran_runs', + ], 'contract'), + record('index', 'Player index', 'PlayerCount, PlayerAt(n)', ['n -> player address', 'paged by players(start, limit)', 'at most 50 per call, no cap'], 'contract'), + record('top', 'TopFresh and TopVeteran', 'Vec, best 100', ['one row per player', 'player, rank_key, floor, score', 'hp_left, actions, name, emblem'], 'contract'), + record('hero', 'Hero', 'Hero(id), HeroCount', ['id, owner, look', 'base, weapon, charm', 'clears (rank)', 'proof_score, proof_hp_left, proof_actions', 'minted_ledger'], 'contract'), + record('owned', 'HeroesOf', 'HeroesOf(Address)', ['delver ids per owner', 'at most 64 per owner'], 'contract'), + ]), + cluster('temporary', 'Temporary storage: expires on its own', 'warn', [ + record('run', 'RunTicket', 'Run(Address), 2 days', ['seed, start_ledger', 'base, weapon, charm, rank', 'look, veteran: Option', 'removed by finish_run'], 'warn'), + ]), + cluster('instance', 'Instance storage: TTL extended on start_run and finish_run, and at deploy', 'asset', [ + record('admin', 'Admin', 'Admin', ['Option
    ', 'removed by renounce_admin'], 'asset'), + record('tokens', 'RelicTokens', 'Relics, set once in the constructor', ['common, rare, core', 'Stellar Asset Contract addresses'], 'asset'), + ]), + edge({ from: 'index', to: 'rec', text: 'address', zone: 'contract' }), + edge({ from: 'top', to: 'rec', text: 'player', zone: 'contract', dashed: true }), + edge({ from: 'owned', to: 'hero', text: 'ids', zone: 'contract' }), + edge({ from: 'run', to: 'hero', text: 'veteran id', zone: 'warn', dashed: true }), + ]), +); diff --git a/diagrams/src/diagrams/07-deployment-map.ts b/diagrams/src/diagrams/07-deployment-map.ts new file mode 100644 index 0000000..eb3a742 --- /dev/null +++ b/diagrams/src/diagrams/07-deployment-map.ts @@ -0,0 +1,31 @@ +import { dotDiagram } from '../diagram'; +import { edge, graph, node } from '../style'; + +export default dotDiagram( + { + name: '07-deployment-map', + title: 'Deployment and verification map', + description: + 'The testnet deployment: the Deepmark contract, three relic Stellar Asset Contracts that it administers, the issuer account that is locked, and the deployer admin account. Each node can be checked on the explorer or with the Stellar CLI.', + }, + () => + graph('TB', [ + node({ id: 'admin', title: 'Deployer and admin account', lines: ['can upgrade until it renounces', 'check: admin() on the contract'], zone: 'warn' }), + node({ id: 'game', title: 'Deepmark contract', lines: ['one instance on testnet', 'check: version(), relic_tokens(), admin()', 'check: wasm hash vs stellar contract fetch', 'instance and wasm TTL extended at deploy'], zone: 'contract' }), + node({ id: 'events', title: 'Contract events', lines: ['run_started, run_finished, hero_earned', 'verify_run replays any run from them'], zone: 'ok' }), + node({ id: 'common', title: 'DMCOMMON SAC', lines: ['admin: Deepmark contract'], zone: 'asset' }), + node({ id: 'rare', title: 'DMRARE SAC', lines: ['admin: Deepmark contract'], zone: 'asset' }), + node({ id: 'core', title: 'DMCORE SAC', lines: ['admin: Deepmark contract'], zone: 'asset' }), + node({ id: 'issuer', title: 'Relic issuer account (locked)', lines: ['master weight 0, no other signer', 'its own key can no longer sign', 'check: the account on the explorer or Horizon'], zone: 'asset' }), + '{ rank=same; common; rare; core; }', + '{ rank=same; admin; events; }', + edge({ from: 'admin', to: 'game', text: 'upgrade, set_admin,\nrenounce_admin', zone: 'warn', dashed: true }), + edge({ from: 'game', to: 'events', text: 'emits', zone: 'ok' }), + edge({ from: 'game', to: 'common', text: 'mint', zone: 'contract' }), + edge({ from: 'game', to: 'rare', text: 'mint', zone: 'contract' }), + edge({ from: 'game', to: 'core', text: 'mint', zone: 'contract' }), + edge({ from: 'common', to: 'issuer', text: 'issued by', zone: 'asset', dashed: true }), + edge({ from: 'rare', to: 'issuer', text: 'issued by', zone: 'asset', dashed: true }), + edge({ from: 'core', to: 'issuer', text: 'issued by', zone: 'asset', dashed: true }), + ]), +); diff --git a/diagrams/src/registry.ts b/diagrams/src/registry.ts new file mode 100644 index 0000000..b54d196 --- /dev/null +++ b/diagrams/src/registry.ts @@ -0,0 +1,10 @@ +import type { Diagram } from './diagram'; +import systemContext from './diagrams/01-system-context'; +import runSequence from './diagrams/02-run-sequence'; +import replay from './diagrams/03-replay-verification'; +import relicTrust from './diagrams/04-relic-trust'; +import clientArchitecture from './diagrams/05-client-architecture'; +import dataModel from './diagrams/06-data-model'; +import deploymentMap from './diagrams/07-deployment-map'; + +export const ALL_DIAGRAMS: readonly Diagram[] = [systemContext, runSequence, replay, relicTrust, clientArchitecture, dataModel, deploymentMap]; diff --git a/diagrams/src/render.ts b/diagrams/src/render.ts new file mode 100644 index 0000000..d0cb1f2 --- /dev/null +++ b/diagrams/src/render.ts @@ -0,0 +1,35 @@ +import { instance } from '@viz-js/viz'; +import { Resvg } from '@resvg/resvg-js'; +import { escapeXml, type DiagramMeta } from './style'; + +let vizPromise: ReturnType | null = null; + +export async function dotToSvg(dot: string): Promise { + vizPromise ??= instance(); + const viz = await vizPromise; + return viz.renderString(dot, { format: 'svg' }); +} + +/** Adds an accessible name and description to the root svg element. */ +export function withAccessibility(svg: string, meta: DiagramMeta): string { + const open = /]*)>/; + if (!open.test(svg)) throw new Error(`No svg element in ${meta.name}`); + const idBase = `dm-${meta.name}`; + return svg.replace( + open, + (_all, attrs: string) => + `` + + `${escapeXml(meta.title)}` + + `${escapeXml(meta.description)}`, + ); +} + +const PNG_WIDTH = 1800; + +export function svgToPng(svg: string): Uint8Array { + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: PNG_WIDTH }, + font: { loadSystemFonts: true, defaultFontFamily: 'Helvetica' }, + }); + return resvg.render().asPng(); +} diff --git a/diagrams/src/sequence.ts b/diagrams/src/sequence.ts new file mode 100644 index 0000000..1935af5 --- /dev/null +++ b/diagrams/src/sequence.ts @@ -0,0 +1,96 @@ +import { COLORS, FONT, ZONES, escapeXml, type Zone } from './style'; + +/** A tiny sequence diagram renderer: lifelines, labelled arrows, notes, phase bands. */ + +export interface Lifeline { + id: string; + title: string; + subtitle: string; + zone: Zone; +} + +export type Step = + | { kind: 'phase'; text: string } + | { kind: 'arrow'; from: string; to: string; lines: readonly string[]; zone?: Zone; dashed?: boolean } + | { kind: 'note'; over: readonly string[]; lines: readonly string[]; zone: Zone }; + +const GAP = 330; +const MARGIN = 70; +const HEADER_HEIGHT = 70; +const LINE_HEIGHT = 18; +const STEP_PAD = 26; +const CHAR_WIDTH = 7.2; + +function text(x: number, y: number, content: string, size: number, fill: string, anchor: string, weight = 'normal', family = FONT): string { + return `${escapeXml(content)}`; +} + +export function renderSequence(lifelines: readonly Lifeline[], steps: readonly Step[]): string { + const xOf = new Map(); + lifelines.forEach((life, i) => xOf.set(life.id, MARGIN + GAP / 2 + i * GAP)); + const column = (id: string): number => { + const x = xOf.get(id); + if (x === undefined) throw new Error(`Unknown lifeline ${id}`); + return x; + }; + const width = MARGIN * 2 + GAP * lifelines.length; + + // First pass: vertical positions. + let y = HEADER_HEIGHT + 20; + const body: string[] = []; + for (const step of steps) { + if (step.kind === 'phase') { + y += 14; + body.push(``); + body.push(text(26, y + 2, step.text, 14, COLORS.gold, 'start', 'bold')); + y += 34; + } else if (step.kind === 'arrow') { + const x1 = column(step.from); + const x2 = column(step.to); + const color = step.zone ? ZONES[step.zone].line : COLORS.edge; + const ink = step.zone ? ZONES[step.zone].ink : COLORS.text; + const mid = (x1 + x2) / 2; + const labelBottom = y + step.lines.length * LINE_HEIGHT; + step.lines.forEach((line, i) => body.push(text(mid, y + i * LINE_HEIGHT, line, 13, ink, 'middle'))); + const ay = labelBottom - 4; + const dir = x2 > x1 ? -1 : 1; + const dash = step.dashed ? ' stroke-dasharray="7 5"' : ''; + body.push(``); + body.push(``); + y = ay + STEP_PAD; + } else { + const xs = step.over.map(column); + const left = Math.min(...xs) - GAP / 2 + 22; + const right = Math.max(...xs) + GAP / 2 - 22; + const style = ZONES[step.zone]; + const height = step.lines.length * LINE_HEIGHT + 16; + body.push(``); + const maxChars = Math.floor((right - left - 24) / CHAR_WIDTH); + step.lines.forEach((line, i) => { + if (line.length > maxChars) throw new Error(`Note line too long (${line.length} > ${maxChars}): ${line}`); + body.push(text(left + 14, y + 3 + i * LINE_HEIGHT, line, 13, style.ink, 'start')); + }); + y += height + 4; + } + } + const height = y + 30; + + const lifelinesSvg = lifelines.map((life) => { + const x = column(life.id); + const style = ZONES[life.zone]; + return [ + ``, + ``, + text(x, 40, life.title, 16, style.ink, 'middle', 'bold'), + text(x, 60, life.subtitle, 12, COLORS.muted, 'middle'), + ].join(''); + }); + + return [ + ``, + ``, + ...lifelinesSvg, + ...body, + '', + ].join('\n'); +} diff --git a/diagrams/src/style.ts b/diagrams/src/style.ts new file mode 100644 index 0000000..1ab59aa --- /dev/null +++ b/diagrams/src/style.ts @@ -0,0 +1,136 @@ +/** One shared look for every diagram: deep blue black ground, warm gold accents. */ + +export const FONT = 'Helvetica'; +export const MONO = 'Courier'; + +export const COLORS = { + background: '#0a0d1a', + panel: '#101528', + text: '#eef0f8', + muted: '#9aa3bd', + edge: '#8c96b4', + gold: '#e8b84a', +} as const; + +/** Semantic zones. Fill is a dark card, line and ink carry the zone colour. */ +export type Zone = 'client' | 'contract' | 'asset' | 'network' | 'danger' | 'ok' | 'warn' | 'plain'; + +export interface ZoneStyle { + fill: string; + line: string; + ink: string; +} + +export const ZONES: Record = { + client: { fill: '#14233f', line: '#5b9bf0', ink: '#cfe3ff' }, + contract: { fill: '#2b2110', line: '#e8b84a', ink: '#ffe9ad' }, + asset: { fill: '#241a3d', line: '#a785ee', ink: '#e3d6ff' }, + network: { fill: '#1b2133', line: '#7f8aa8', ink: '#dde2f0' }, + danger: { fill: '#34161c', line: '#ee6a63', ink: '#ffd3cf' }, + ok: { fill: '#10302a', line: '#4cc79a', ink: '#c8f5e3' }, + warn: { fill: '#33200f', line: '#f0913a', ink: '#ffdcb8' }, + plain: { fill: '#141a2e', line: '#56607f', ink: '#dde2f0' }, +}; + +export function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export function escapeXml(text: string): string { + return escapeHtml(text).replace(/'/g, '''); +} + +/** Graphviz HTML label: a bold title plus smaller muted lines. */ +export function label(title: string, lines: readonly string[] = [], zone: Zone = 'plain'): string { + const style = ZONES[zone]; + const rows = [ + `
    ${escapeHtml(title)}`, + ...lines.map( + (line) => + `${escapeHtml(line)}`, + ), + ]; + return `<${rows.join('')}
    >`; +} + +/** Record style field list: a title row, then one left aligned row per field. */ +export function recordLabel(title: string, fields: readonly string[], note: string, zone: Zone): string { + const style = ZONES[zone]; + const rows = [ + `${escapeHtml(title)}`, + `${escapeHtml(note)}`, + ...fields.map( + (field) => + `${escapeHtml(field)}`, + ), + ]; + return `<${rows.join('')}
    >`; +} + +export interface NodeSpec { + id: string; + title: string; + lines?: readonly string[]; + zone?: Zone; + shape?: string; + style?: string; +} + +export function node(spec: NodeSpec): string { + const zone = spec.zone ?? 'plain'; + const style = ZONES[zone]; + const shape = spec.shape ?? 'box'; + return `${spec.id} [label=${label(spec.title, spec.lines ?? [], zone)}, shape=${shape}, style="${spec.style ?? 'rounded,filled'}", fillcolor="${style.fill}", color="${style.line}"];`; +} + +export interface EdgeSpec { + from: string; + to: string; + text?: string; + zone?: Zone; + dashed?: boolean; + extra?: string; +} + +export function edge(spec: EdgeSpec): string { + const color = spec.zone ? ZONES[spec.zone].line : COLORS.edge; + const attrs = [`color="${color}"`, `fontcolor="${spec.zone ? ZONES[spec.zone].ink : COLORS.text}"`]; + if (spec.text) attrs.push(`label=${labelText(spec.text)}`); + if (spec.dashed) attrs.push('style=dashed'); + if (spec.extra) attrs.push(spec.extra); + return `${spec.from} -> ${spec.to} [${attrs.join(', ')}];`; +} + +/** Edge label as an HTML label so the font is set explicitly. */ +function labelText(text: string): string { + const rows = text.split('\n').map((line) => escapeHtml(line)).join('
    '); + return `<
    ${rows}
    >`; +} + +export function cluster(id: string, title: string, zone: Zone, body: readonly string[]): string { + const style = ZONES[zone]; + return `subgraph cluster_${id} { + label=<${escapeHtml(title)}>; + labeljust=l; style="rounded,filled"; fillcolor="${COLORS.background}"; color="${style.line}"; penwidth=1.6; margin=18; + ${body.join('\n ')} +}`; +} + +/** Wraps graph content with the shared graph, node and edge defaults. */ +export function graph(direction: 'LR' | 'TB', body: readonly string[], extra = ''): string { + return `digraph G { + rankdir=${direction}; bgcolor="${COLORS.background}"; pad=0.4; nodesep=0.45; ranksep=0.75; + fontname="${FONT}"; splines=true; ${extra} + node [fontname="${FONT}", penwidth=1.8, margin="0.22,0.12"]; + edge [fontname="${FONT}", penwidth=1.5, arrowsize=0.9]; + ${body.join('\n ')} +}`; +} + +export interface DiagramMeta { + /** File stem and also used for ids. */ + name: string; + title: string; + /** Accessible description, also the suggested README alt text. */ + description: string; +} diff --git a/diagrams/tsconfig.json b/diagrams/tsconfig.json new file mode 100644 index 0000000..0345c99 --- /dev/null +++ b/diagrams/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/docs/architecture/nft-standard.md b/docs/architecture/delvers-and-nft-standard.md similarity index 94% rename from docs/architecture/nft-standard.md rename to docs/architecture/delvers-and-nft-standard.md index b43ec77..0ed8f6e 100644 --- a/docs/architecture/nft-standard.md +++ b/docs/architecture/delvers-and-nft-standard.md @@ -1,6 +1,6 @@ # Delvers and the Stellar NFT standard -Research note, written on 2026-10-10. It compares the delver entry points of the game contract with the current Stellar standard for non-fungible tokens and says what would be needed to comply. It does not claim explorer support, and the last section says what was and was not checked. +Research note, written on 2026-10-10. It compares the delver entry points of the game contract with the current Stellar standard for non-fungible tokens and says what would be needed to comply. It does not claim explorer support, and the last section says what was and was not checked. Relics are a different thing: they are classic Stellar assets wrapped by Stellar Asset Contracts, so they are standard tokens and are not covered here (see [`onchain-boundary.md`](onchain-boundary.md) and [`../deployments.md`](../deployments.md)). Only delvers are compared below. ## What was consulted @@ -8,7 +8,7 @@ Research note, written on 2026-10-10. It compares the delver entry points of the |---|---| | SEP-0050, Non-Fungible Tokens | Status Draft, version 0.1.0, created and updated 2025-03-10. The file was last changed in `stellar/stellar-protocol` on 2025-06-17 (commit `d4d17e6`) | | OpenZeppelin `stellar-contracts`, `non_fungible` module, `NonFungibleToken` trait | Release `v0.7.2`, 2026-06-09 (commit `a9c4216`). The newest release, `v0.8.0-rc.3` of 2026-06-16, has a byte for byte identical `non_fungible/mod.rs` | -| Deployed game contract interface | Read from testnet with `stellar contract info interface` for the contract that was live when this note was written (`CDL7OO7MGCNMHZBF5QPJCMBLLY3ORR44TJ54ON44M25YUX6UZ7L46UZW`, since replaced, see the README for the current id) | +| Deployed game contract interface | Read from testnet with `stellar contract info interface` for the contract that was live when this note was written (`CDL7OO7MGCNMHZBF5QPJCMBLLY3ORR44TJ54ON44M25YUX6UZ7L46UZW`, since replaced, see [`../deployments.md`](../deployments.md) for the current id) | SEP-0050 is a draft authored by OpenZeppelin, and the OpenZeppelin module is its reference implementation, so the two are compared together. The standard also notes a classic alternative, SEP-0039 plus a Stellar Asset Contract, and the reasons it is a poor fit (one asset per token, or fungible tokens inside one asset). Delvers carry a look, a kit and an on-chain proof, which that route cannot hold. diff --git a/docs/architecture/onchain-boundary.md b/docs/architecture/onchain-boundary.md index e5c0830..8071031 100644 --- a/docs/architecture/onchain-boundary.md +++ b/docs/architecture/onchain-boundary.md @@ -2,11 +2,25 @@ Deepmark is a turn-based dungeon-crawler roguelite. The loop: enter a run, descend up to 3 floors, fight or avoid the enemy guarding each floor's shortcut, grab relics, then choose to extract your gains or push deeper. Death or extraction ends the run. +Terms used below: + +- **Soroban**: Stellar's smart contract platform. The game contract is a Soroban contract. +- **Stellar Asset Contract (SAC)**: a contract that exposes a classic Stellar asset to smart contracts. Each relic asset has one. +- **Replay**: running the recorded moves through the same rules, from the same seed, to get the result again. +- **Golden vectors**: runs played by the real TypeScript game and stored with their results, so `cargo test` can check the Rust replay gives the same answer byte for byte. +- **TTL**: how many ledgers a stored entry lives before it is archived unless its lifetime is extended. + The rule that shapes everything below: **the contract never trusts a claimed result.** The whole game is small and deterministic, so the contract replays each run itself and derives the result. ## How a run touches the chain -1. `start_run(player, hero)`: the contract issues the run's seed on chain and stores an open run for the player. The client plays the run with that seed. This happens while the descent animation plays. +Diagrams are generated in `diagrams/` and are embedded here once built. + +![System context of the browser, Stellar services, the game contract and the relic contracts.](../../diagrams/out/svg/01-system-context.svg) + +![One run as two transactions.](../../diagrams/out/svg/02-run-sequence.svg) + +1. `start_run(player, choice)`: the contract issues the run's seed on chain and stores an open run for the player. The client plays the run with that seed. This happens while the descent animation plays. 2. The player plays entirely locally. Every input that changed the game is appended to an action log, one byte each (move up, down, left, right, attack, retreat, descend, extract). 3. `finish_run(player, actions)`: after the result screen is already showing, the client sends the log. The contract replays it with a Rust port of the game simulation, derives the floor, relics, score and HP, ranks the run, updates the profile and mints the banked relics. @@ -14,7 +28,7 @@ If Stellar is unreachable, the game falls back to a practice run that is labelle ## Earned delvers -Every descent starts by building a delver: name, emblem, cloak, a base, one weapon and one charm from predefined items. `start_run` validates all of it and fixes it in the run's ticket. Only a fresh delver that extracts from floor 3 in a verified replay becomes the player's own, as a token stored in the contract with the verified clear as its proof. Dying first loses the character. An owned delver can descend again as a veteran, gaining one hit point of rank per clear up to three, and veterans have their own leaderboard so they never compete with fresh delvers. +Every descent starts by building a delver: name, emblem, cloak, a base, one weapon and one charm from predefined items. `start_run` validates all of it and fixes it in the run's ticket. Only a fresh delver that extracts from floor 3 in a verified replay becomes the player's own, as an owned record stored in the contract with the verified clear as its proof. It is not a standard token, see `delvers-and-nft-standard.md`. Dying first loses the character. An owned delver can descend again as a veteran, gaining one hit point of rank per clear up to three, and veterans have their own leaderboard so they never compete with fresh delvers. ## On-chain (Soroban) @@ -23,10 +37,11 @@ Contract: `contracts/deepmark-contract`. - **Seed.** Derived from ledger sequence, ledger time, the player's run counter and address. The client cannot choose it. - **Replay.** `sim.rs` is a bit exact port of `client/src/game/`: the same Mulberry32 generator, the same floors, enemies and delvers. Golden vectors played by the real TypeScript game are checked byte for byte by `cargo test`, so the two cannot drift silently. - **Profile.** Per address: best verified run, runs, extractions, deaths, abandoned runs, relics banked. -- **Leaderboard.** One sorted list of the best 100 verified runs, read in a single call, ranked by extraction, score, floor, HP left, then fewest actions. The number of players is not capped, and players are listed through a paged call. -- **Audit.** `verify_run(hero, seed, actions)` replays any run without writing anything, so anyone can check a result from the `run_started` event and the log. +- **Leaderboards.** Two sorted lists, `top_fresh` for fresh delvers and `top_veteran` for veterans, each the best 100 verified runs with one row per player, read in a single call each, ranked by extraction, score, floor, HP left, then fewest actions. The number of players is not capped, and players are listed through a paged call. +- **Audit.** `verify_run(base, weapon, charm, rank, seed, actions)` replays any run without writing anything, so anyone can check a result from the `run_started` event and the log. A worked example with real values is in the README. - **Delvers.** The contract is also the registry of earned delvers: `owner_of`, `heroes_of`, `hero`, `balance` and `transfer`. Mint happens only inside `finish_run`, after the replay. -- **Relic assets.** Three classic assets (`DMCOMMON`, `DMRARE`, `DMCORE`), each wrapped by a Stellar Asset Contract. The game contract is the admin of each, and the issuer account is locked (master weight zero, no other signer), so nothing can mint except a verified extraction. +- **Relic assets.** Three classic assets (`DMCOMMON`, `DMRARE`, `DMCORE`), each wrapped by a Stellar Asset Contract. The game contract is the admin of each, and the issuer account is locked (master weight zero, no other signer), so nothing can mint except a verified extraction. If a mint is rejected, for example because the player has no trustline for that relic, `finish_run` returns `MintFailed` and reverts, so the run stays open and can be finished again. +- **Storage lifetime.** Writes extend the entry they touch to 120 days when it has under 30 left. `start_run` also renews the player counter, the delver counter, both leaderboards and the delver a veteran run uses, and the contract instance is extended on every start and finish. The deployment itself extended the instance and wasm lifetimes, so the first call is not charged that rent. Records of players who stop playing are not renewed and can expire after about 120 days. - **Admin and upgrade.** The game contract has an admin (the deployer) that can replace its code, hand the role to another address or renounce it. While the role exists, players trust that admin not to ship code that skips the replay. Renouncing, or moving the role to a multisig, is the step that removes that trust, and is planned before any mainnet release. ## Off-chain (client) @@ -47,15 +62,17 @@ Contract: `contracts/deepmark-contract`. ## What it does not provide -- **Seed rerolling is limited, not eliminated.** Starting another run costs a transaction and a short cooldown, and an abandoned run is counted on the profile, but a determined player can still discard seeds. +- **Seed rerolling is limited, not eliminated.** Starting another run costs a transaction and a short cooldown, and an abandoned run is counted on the profile, but both are per address. A determined player can discard seeds, and a new funded address starts with a clean record. +- **Relics have no supply cap.** Only a verified extraction mints, but a bot that solves seeds offline can run many accounts. A relic proves a replayed legal run, not a distinct person. - **A solver can play a legal perfect run.** Any deterministic game allows it. The result is still a run the game could produce, and ties are broken by HP left and fewest actions. +- **New players register one per ledger.** A new player is appended to a sequential index (`PlayerAt(i)`), so several brand new players whose `start_run` lands in the same ledger simulate the same next index, and the later transactions fail with `storage exceeded_limit` on the key `PlayerAt N`. In the playtest, 3 fresh accounts starting together gave 2 failures out of 3. This is a throughput limit of new player registration, about one per ledger (about 5 seconds). The client detects a failed `start_run` (`client/src/stellar/txOutcome.ts`), retries up to 4 times with jittered backoff and re-simulates, and a local re-run had 6 of 6 starts succeed. A returning player is never affected. A registry keyed by address would remove the limit in a later contract version. - **The keypair lives in the browser.** Clearing storage loses the identity and its records. A passkey smart account is the planned replacement. - **Delvers are not yet standard NFTs.** They live in the game contract with a compatible shape, and the exact standard is to be verified before any claim of wallet support. - **Testnet only** until a mainnet deployment is made. ## Costs measured -The worst case `finish_run` in the test suite (2,000 inputs of wall bumps plus a legal path) uses about 3.6 million CPU instructions against the 400 million limit. The contract wasm is about 16 KB. +The worst case `finish_run` in the test suite (2,000 inputs of wall bumps plus a legal path, `the_worst_case_log_stays_far_under_the_cpu_budget` in `src/test.rs`) uses about 3.6 million CPU instructions against the 400 million limit. The deployed wasm is 28,107 bytes, fetched with `stellar contract fetch` on 2026-10-12 and matching the sha256 in `docs/deployments.md`. Fees of real transactions on testnet are in the README under "Measured costs": almost all of every fee is one time storage rent, a new player pays about 0.79 XLM once, and a later `start_run` costs about 0.006 XLM. ## What changed from the first design diff --git a/docs/business-model.md b/docs/business-model.md new file mode 100644 index 0000000..b3df39f --- /dev/null +++ b/docs/business-model.md @@ -0,0 +1,46 @@ +# Business model + +Deepmark is a free game. There is no token and nothing for sale that makes a delver stronger. This page says how it could be funded without breaking that, and what is not planned. Nothing here is signed, live or earning today, and none of the numbers it would depend on have been measured yet. + +## What stays fixed + +- No speculative token. +- Nothing pay to win, directly or through a market. +- Only a replay verified extraction can mint a relic or earn a delver. +- The game must be fun with the blockchain ignored. +- No server holds a player's keys. + +Every option below was checked against those rules, and the ones that fail them are listed at the end. + +## The plan: sponsored seasonal ladders + +A season is a few weeks with a fixed ruleset and a ladder ranked by the order the contract already uses: extraction, score, floor, HP left, then fewest actions. A sponsor funds the prizes. Entry is free for players. Prizes would be paid only to ranks that `verify_run` can reproduce, so a sponsor can audit that its money went to results that were legitimately played. + +That auditable payout is the thing a sponsor would be buying, together with the attention of the players on the ladder. + +What has to be true first: + +- **Enough players to be worth sponsoring.** There is no player count worth quoting yet. +- **A ruleset that resists solvers.** The game is deterministic and the seed is public, so a bot can play a legal perfect run. A prize ladder needs procedural floors or a seed issued late, and protection against one person running many accounts. The [threat model](threat-model.md) describes both gaps. +- **Sponsored fees.** A new player cannot pay fees without holding XLM, so a ladder needs a fee sponsor that does not hold player keys. The testnet fees we measured are in the README. +- **Legal review** of prizes in the places players live. This is not done. + +## Cost side + +Fee sponsorship is a cost, not income, and it is what makes the rest possible. It is funded from sponsorship and grants. The abuse risk is bots spamming `start_run`, which the cooldown and per account limits would have to contain. We have not measured mainnet fees and will not quote a cost per player until we have. + +## Smaller pieces + +- **Optional cosmetics.** Looks only, with no effect on play. An earned delver keeps a mark that cannot be bought, so a bought look is never mistaken for a verified clear. +- **An open replay kit.** The way Deepmark replays runs could help other small deterministic games. The idea is to publish it as open source under the existing licence first, and offer integration help only if another studio asks. + +## What is not planned + +- **A token, of any kind.** Not without publicly rewriting the principles first. +- **Entry fees for tournaments.** They bring gambling and licensing exposure and push toward pay to play. Sponsor funded free entry is the version we would run. +- **A marketplace fee on delvers.** A market would let someone buy a won delver, which undoes the point of verification. +- **Selling power, relics or ladder places.** + +## Honest summary + +On the numbers we can imagine, this is a project funded by grants and sponsors, and a small studio's portfolio, not a venture scale business. If that changes it will be because the audience is much larger or because other studios use the replay kit. We will say so here when we know. diff --git a/docs/deployments.md b/docs/deployments.md index 34753c7..680dc40 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -2,68 +2,136 @@ Every id and transaction below is on Stellar testnet and can be looked up on [stellar.expert](https://stellar.expert/explorer/testnet) or in Horizon. Times are UTC. -## Current deployment, 2026-10-12 +## Current deployment, 2026-10-12 (evening) + +Deployed and exercised with the Stellar CLI and the real client. Times are UTC and the deployment record is kept outside this repository. + +| Item | Id | +|---|---| +| Game contract | [`CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX`](https://stellar.expert/explorer/testnet/contract/CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX) | +| `DMCOMMON` contract | [`CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R`](https://stellar.expert/explorer/testnet/contract/CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R) | +| `DMRARE` contract | [`CCQFEYZR3D4XHTMLV3XJZBTZUT4WXZS2LUXHPFTH7DRRZENZNU2NVZ5L`](https://stellar.expert/explorer/testnet/contract/CCQFEYZR3D4XHTMLV3XJZBTZUT4WXZS2LUXHPFTH7DRRZENZNU2NVZ5L) | +| `DMCORE` contract | [`CAHKK75BOXGBKSXBB3EFPWS6C3IZIXIFLYI3A74GYCRSI4U6NKVZTLIM`](https://stellar.expert/explorer/testnet/contract/CAHKK75BOXGBKSXBB3EFPWS6C3IZIXIFLYI3A74GYCRSI4U6NKVZTLIM) | +| Relic issuer (locked) | [`GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q`](https://stellar.expert/explorer/testnet/account/GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q) | +| Game admin and deployer | [`GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX`](https://stellar.expert/explorer/testnet/account/GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX) | +| Wasm | 28,107 bytes, sha256 `db721cb97242bf5d9710d70752959a5658c2be81fee70d5fdd7253b878f80d07` | + +The wasm was fetched with `stellar contract fetch --id --network testnet` on 2026-10-12 and its size and sha256 match the line above. The issuer was read back from Horizon: one signer with weight 0, thresholds 0 / 0 / 0, no authorization flags. `admin()` on `DMCOMMON` returns the game contract (checked above in the README, and by simulation on all three relic contracts). + +### Setup transactions + +In order, 18:29 to 18:30 UTC. Every hash was read back from Horizon and is `successful`. + +| Time | Step | Transaction | +|---|---|---| +| 18:29:37 | Create the issuer account | [`22642ffb30c2ec71bad745fb7269fe17c5a048805e0bfbeb7f40367e866d9bff`](https://stellar.expert/explorer/testnet/tx/22642ffb30c2ec71bad745fb7269fe17c5a048805e0bfbeb7f40367e866d9bff) | +| 18:29:42 | Create the deployer account | [`d5db5a13165a3be9d646adf9c424b62f721384a44ae559a3fda91b9aa982d047`](https://stellar.expert/explorer/testnet/tx/d5db5a13165a3be9d646adf9c424b62f721384a44ae559a3fda91b9aa982d047) | +| 18:29:47 | Deploy the Stellar Asset Contract of the first relic asset | [`86f218b79270a684a63db9598df2315316f94561a3ff8fd66c8f49d295cddbe9`](https://stellar.expert/explorer/testnet/tx/86f218b79270a684a63db9598df2315316f94561a3ff8fd66c8f49d295cddbe9) | +| 18:29:52 | Deploy the Stellar Asset Contract of the second relic asset | [`2337cf6a15a699001aebc51a271c1fbebf65227e1fcc4ef2e71b2377d627c23f`](https://stellar.expert/explorer/testnet/tx/2337cf6a15a699001aebc51a271c1fbebf65227e1fcc4ef2e71b2377d627c23f) | +| 18:29:57 | Deploy the Stellar Asset Contract of the third relic asset | [`eed89eb140d5c55d2fb460996092de12b224f9d67aa7a11902deb632ae66830b`](https://stellar.expert/explorer/testnet/tx/eed89eb140d5c55d2fb460996092de12b224f9d67aa7a11902deb632ae66830b) | +| 18:30:02 | Upload the game wasm | [`593d2ea0e7a185d29bb8f498755c33acb69138aee50aab8208a016e51ac6369f`](https://stellar.expert/explorer/testnet/tx/593d2ea0e7a185d29bb8f498755c33acb69138aee50aab8208a016e51ac6369f) | +| 18:30:07 | Deploy the game contract, with the admin and the three relic contracts as constructor arguments | [`dd0f5f24645ebe85f14e63ec05a38b51e0979f29032dd14980990dd0d5d61313`](https://stellar.expert/explorer/testnet/tx/dd0f5f24645ebe85f14e63ec05a38b51e0979f29032dd14980990dd0d5d61313) | +| 18:30:12 | Extend the lifetime of the contract instance (`extend_footprint_ttl`) | [`3a174eec310202b9124525f8099e6228a8042c310ce08838b7a2b47a0609d4ba`](https://stellar.expert/explorer/testnet/tx/3a174eec310202b9124525f8099e6228a8042c310ce08838b7a2b47a0609d4ba) | +| 18:30:17 | Extend the lifetime of the wasm code (`extend_footprint_ttl`) | [`ae8a038a8470479fc2fa603dee70d718b7f75838bed101d13a09739661c389fb`](https://stellar.expert/explorer/testnet/tx/ae8a038a8470479fc2fa603dee70d718b7f75838bed101d13a09739661c389fb) | +| 18:30:22 | Set the game contract as admin of `DMCOMMON` | [`bca24a68f421f526b68e555a90a52724d1c99dca947cae2846040d1ac95798ab`](https://stellar.expert/explorer/testnet/tx/bca24a68f421f526b68e555a90a52724d1c99dca947cae2846040d1ac95798ab) | +| 18:30:27 | Set the game contract as admin of `DMRARE` | [`f80fac47b939a10364514d4cd93d54b0d8b27d17fa2a8ce9cb59e0aa3df22d95`](https://stellar.expert/explorer/testnet/tx/f80fac47b939a10364514d4cd93d54b0d8b27d17fa2a8ce9cb59e0aa3df22d95) | +| 18:30:32 | Set the game contract as admin of `DMCORE` | [`94b33a09e5d6e95ce073fbfd970963600b5cbdaec6c5ab72d74869af00825881`](https://stellar.expert/explorer/testnet/tx/94b33a09e5d6e95ce073fbfd970963600b5cbdaec6c5ab72d74869af00825881) | +| 18:30:37 | Lock the issuer: master key weight set to 0 | [`ddae4f0c5f28ff1bc08af97949dd85861fe3b087eefb67b1e4893b354c514e28`](https://stellar.expert/explorer/testnet/tx/ddae4f0c5f28ff1bc08af97949dd85861fe3b087eefb67b1e4893b354c514e28) | + +The `set_admin` steps target `DMCOMMON`, `DMRARE` and `DMCORE` in that order, as read from each transaction's arguments. The three Stellar Asset Contract deploys are listed as first, second and third because their asset was not decoded. Each step closed in the next ledger it could, five seconds after the one before. There is no deploy script in this repository, the steps were run by hand. + +### Playtest on the new deployment + +Six runs through the real client (production build, headless Chrome), each on a fresh throwaway account funded by Friendbot. The seed of every run was issued by the contract in `start_run`, and the winning key sequences were computed offline with the repository's own simulation and replayed as key presses. All transactions are `successful`. + +| # | Run | `start_run` | `finish_run` | Result | +|---|---|---|---|---| +| 1 | Early extraction, floor 1 (first call on the contract) | [`d1dc76e76d4498adec6396fccdc9cad8e0314b832c8d0f0e8908a461c76e100b`](https://stellar.expert/explorer/testnet/tx/d1dc76e76d4498adec6396fccdc9cad8e0314b832c8d0f0e8908a461c76e100b) | [`aecb8b3fa89b0340ce84b6695ea2570d3e327de010e79f460447e63c6d53db6a`](https://stellar.expert/explorer/testnet/tx/aecb8b3fa89b0340ce84b6695ea2570d3e327de010e79f460447e63c6d53db6a) | 27 actions, score 1, 1 `DMCOMMON` | +| 2 | Extraction, floor 2 | [`a6ebcd48129c055face7b92f484343f098e673c59083ed06245669203f6d91e8`](https://stellar.expert/explorer/testnet/tx/a6ebcd48129c055face7b92f484343f098e673c59083ed06245669203f6d91e8) | [`51d74c53126f42064f0208346da18ee941289bc1009ae72e8b9b8fd70380e7cc`](https://stellar.expert/explorer/testnet/tx/51d74c53126f42064f0208346da18ee941289bc1009ae72e8b9b8fd70380e7cc) | 48 actions, score 3, 1 `DMCOMMON`, 1 `DMRARE` | +| 3 | Death on floor 2 | [`967ec9d6e577d8025082b1f614d44458f5dd81c26ad199d9f3e9e0222fd37b21`](https://stellar.expert/explorer/testnet/tx/967ec9d6e577d8025082b1f614d44458f5dd81c26ad199d9f3e9e0222fd37b21) | [`bd2647098500dc954db3a4c989471ba703110343343798982587b91566c54841`](https://stellar.expert/explorer/testnet/tx/bd2647098500dc954db3a4c989471ba703110343343798982587b91566c54841) | 29 actions, score 0, no relics | +| 4 | Full clear, fresh delver (id 0) | [`7abbf360511ef3d548b167a69507c9cd57272e5ff93256277da12fd1df441f09`](https://stellar.expert/explorer/testnet/tx/7abbf360511ef3d548b167a69507c9cd57272e5ff93256277da12fd1df441f09) | [`7881adabe0dd17c9319a9b975d8c7e76796bb112214df5dac114d31805159334`](https://stellar.expert/explorer/testnet/tx/7881adabe0dd17c9319a9b975d8c7e76796bb112214df5dac114d31805159334) | 76 actions, score 6, 3 relics, `hero_earned` 0 | +| 5 | Full clear, fresh delver (id 1) | [`5fdccef0e229953a20cd832e6dce9d5732065ee604b3a3c1b9cec3ec791ca090`](https://stellar.expert/explorer/testnet/tx/5fdccef0e229953a20cd832e6dce9d5732065ee604b3a3c1b9cec3ec791ca090) | [`349e4df814b2a0f3b6a530d05855a5d70f935a321fac37067de62a6de12e7524`](https://stellar.expert/explorer/testnet/tx/349e4df814b2a0f3b6a530d05855a5d70f935a321fac37067de62a6de12e7524) | 75 actions, score 6, 3 relics, `hero_earned` 1 | +| 6 | Veteran run with delver 1 (same account as run 5) | [`9ca436ee1f760f5d5d8a1cb8b8c12263dfca98423af55f78a1660fd77aa3a9cc`](https://stellar.expert/explorer/testnet/tx/9ca436ee1f760f5d5d8a1cb8b8c12263dfca98423af55f78a1660fd77aa3a9cc) | [`5b74020c03ac5dc0e43d4092ee2dd3338eaf220dafc76f67ce521bab53609c3f`](https://stellar.expert/explorer/testnet/tx/5b74020c03ac5dc0e43d4092ee2dd3338eaf220dafc76f67ce521bab53609c3f) | 46 actions, score 3, 1 `DMCORE`, delver 1 now at 2 clears | + +Checks after the six runs, read from Horizon and the contract: + +- The authorized supply equals the mints in the `run_finished` events: 4 `DMCOMMON`, 3 `DMRARE`, 3 `DMCORE`. Claimable balances and contract holdings are 0. +- The holder counts Horizon shows for the three assets count throwaway test accounts (each one holds all three trustlines), not players, and they grow with every test pass. Some of those accounts only opened a run and never finished it. +- `verify_run` replayed runs 3, 5 and 6 read only and matched their `run_finished` events. The command for run 5 is in the README. +- `top_fresh` lists five players and `top_veteran` one, as expected from the runs. +- Fees and rent of each transaction are in the README under "Measured costs". The first `start_run` cost 8,970,532 stroops (0.897 XLM), against 183.9 XLM on the superseded deployment. + +Client side issues seen during the playtest (the contract is not involved): when three fresh accounts started at the same moment, two of them fell back to a practice run. The cause is the sequential player index, described in the README under Limitations, and the client now retries a failed `start_run`. Also, the veteran result screen still shows the copy for a new delver although the contract only raised the existing delver's clears. Single sequential starts never failed. + +## Superseded deployment, 2026-10-12 morning + +This deployment is replaced by the one above. It had four known problems that the new contract fixes: `transfer` to the same owner duplicated the delver in that owner's list, a failed relic mint was recorded as `minted: false` and never retried, `relic_tokens` and the mint path used `unwrap()`, and `start_run` did not renew the shared counters, the leaderboards or a veteran. The instance and wasm lifetimes were also not extended at deploy time, which is the likely reason its first `start_run` cost 183.9 XLM (not isolated further). Its relics and runs do not migrate. The client no longer reads it. | Item | Id | |---|---| -| Game contract | `CDEAFSBG7JBN6XMP4HRL7LFBL7L67K4RJK3CIRBV23W3TJQCEADZ7R6N` | -| `DMCOMMON` contract | `CAAFYL2QZU274NLA2IFDB4L6SUNVQKT6OLB6UXGL5L5LMQCFJXSUYFO3` | -| `DMRARE` contract | `CC4RQ4HLTPTXMCZ3OKDL546XXJGOQ5PI2ET6U33C2XTO4BTS6AJV4ANA` | -| `DMCORE` contract | `CC27JVKNDSUM5FDGP4JZDZTKGHJ5RJV7XHJGITTUGOUFE4DVPN6PGLMI` | -| Relic issuer | `GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK` | -| Game admin and deployer | `GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ` | +| Game contract | [`CDEAFSBG7JBN6XMP4HRL7LFBL7L67K4RJK3CIRBV23W3TJQCEADZ7R6N`](https://stellar.expert/explorer/testnet/contract/CDEAFSBG7JBN6XMP4HRL7LFBL7L67K4RJK3CIRBV23W3TJQCEADZ7R6N) | +| `DMCOMMON` contract | [`CAAFYL2QZU274NLA2IFDB4L6SUNVQKT6OLB6UXGL5L5LMQCFJXSUYFO3`](https://stellar.expert/explorer/testnet/contract/CAAFYL2QZU274NLA2IFDB4L6SUNVQKT6OLB6UXGL5L5LMQCFJXSUYFO3) | +| `DMRARE` contract | [`CC4RQ4HLTPTXMCZ3OKDL546XXJGOQ5PI2ET6U33C2XTO4BTS6AJV4ANA`](https://stellar.expert/explorer/testnet/contract/CC4RQ4HLTPTXMCZ3OKDL546XXJGOQ5PI2ET6U33C2XTO4BTS6AJV4ANA) | +| `DMCORE` contract | [`CC27JVKNDSUM5FDGP4JZDZTKGHJ5RJV7XHJGITTUGOUFE4DVPN6PGLMI`](https://stellar.expert/explorer/testnet/contract/CC27JVKNDSUM5FDGP4JZDZTKGHJ5RJV7XHJGITTUGOUFE4DVPN6PGLMI) | +| Relic issuer (locked) | [`GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK`](https://stellar.expert/explorer/testnet/account/GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK) | +| Game admin and deployer | [`GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ`](https://stellar.expert/explorer/testnet/account/GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ) | | Wasm sha256 | `256f445c6e3e31708f7a52257a8145afffc0cd9b12abf9f2657cfe5379fbcf2c` | The wasm hash can be checked against the chain with `stellar contract fetch --id --network testnet` followed by `shasum -a 256` on the output. -### Setup transactions +### Setup transactions (superseded) In order. The first column is the ledger close time of the transaction. | Time | Step | Transaction | |---|---|---| -| 03:53:57 | Create the issuer account | `705b19f25b8325ab390cedbce7a5a7687f10bd1f83729d2d52dc23cddebaf5ea` | -| 03:54:02 | Create the deployer account | `340f96f54028f3909e81a37223005afcffc76cf0e5865ce2298abf2e69439147` | -| 03:54:07 | Deploy the Stellar Asset Contract of the first relic asset | `d39f4d9eff766c03d93d474d6f96b181e8cd2d02723587542b2b330d34819d31` | -| 03:54:12 | Deploy the Stellar Asset Contract of the second relic asset | `1351cee92dd107ecb0d534239e5ec7becae90b8e31638b8ed6ce1de30b8a80e4` | -| 03:54:17 | Deploy the Stellar Asset Contract of the third relic asset | `ae5e077d040f4b5dba254c8decb6e22dcd17c1cd2f5329c2464eea825184e2f5` | -| 03:54:22 | Upload the game wasm | `fa6a32a4cbfbc5ed2dbbf272f3717d5f56efae0375e4e7d9532beea196dfea04` | -| 03:54:27 | Deploy the game contract, with the admin as constructor argument | `737080c6b5c25fcc2f5726bf8327ca3a73dcec06c40ee3dc2b42f52224d935c2` | -| 03:54:32 | Set the game contract as admin of the first relic contract | `9d000b0a1b89168a9fc531a69c48d5e9f39e1b3ec98da79c5f9f03b951c4f9c6` | -| 03:54:37 | Set the game contract as admin of the second relic contract | `0722fdcbb03d87d21f85f3fa2af4bc9c6071b5d275ce572cc8c6b3dc9039fb23` | -| 03:54:42 | Set the game contract as admin of the third relic contract | `9e606376fd11b1394aea1e035e73462fd7712772a3bd4fc0a19c2cdc9406fcec` | -| 03:54:47 | Lock the issuer: master key weight set to 0 | `792dd0919d33938e0603b3ea3a4e6bac4a40de61f8680701dbbf25746447fc40` | +| 03:53:57 | Create the issuer account | [`705b19f25b8325ab390cedbce7a5a7687f10bd1f83729d2d52dc23cddebaf5ea`](https://stellar.expert/explorer/testnet/tx/705b19f25b8325ab390cedbce7a5a7687f10bd1f83729d2d52dc23cddebaf5ea) | +| 03:54:02 | Create the deployer account | [`340f96f54028f3909e81a37223005afcffc76cf0e5865ce2298abf2e69439147`](https://stellar.expert/explorer/testnet/tx/340f96f54028f3909e81a37223005afcffc76cf0e5865ce2298abf2e69439147) | +| 03:54:07 | Deploy the Stellar Asset Contract of the first relic asset | [`d39f4d9eff766c03d93d474d6f96b181e8cd2d02723587542b2b330d34819d31`](https://stellar.expert/explorer/testnet/tx/d39f4d9eff766c03d93d474d6f96b181e8cd2d02723587542b2b330d34819d31) | +| 03:54:12 | Deploy the Stellar Asset Contract of the second relic asset | [`1351cee92dd107ecb0d534239e5ec7becae90b8e31638b8ed6ce1de30b8a80e4`](https://stellar.expert/explorer/testnet/tx/1351cee92dd107ecb0d534239e5ec7becae90b8e31638b8ed6ce1de30b8a80e4) | +| 03:54:17 | Deploy the Stellar Asset Contract of the third relic asset | [`ae5e077d040f4b5dba254c8decb6e22dcd17c1cd2f5329c2464eea825184e2f5`](https://stellar.expert/explorer/testnet/tx/ae5e077d040f4b5dba254c8decb6e22dcd17c1cd2f5329c2464eea825184e2f5) | +| 03:54:22 | Upload the game wasm | [`fa6a32a4cbfbc5ed2dbbf272f3717d5f56efae0375e4e7d9532beea196dfea04`](https://stellar.expert/explorer/testnet/tx/fa6a32a4cbfbc5ed2dbbf272f3717d5f56efae0375e4e7d9532beea196dfea04) | +| 03:54:27 | Deploy the game contract, with the admin as constructor argument | [`737080c6b5c25fcc2f5726bf8327ca3a73dcec06c40ee3dc2b42f52224d935c2`](https://stellar.expert/explorer/testnet/tx/737080c6b5c25fcc2f5726bf8327ca3a73dcec06c40ee3dc2b42f52224d935c2) | +| 03:54:32 | Set the game contract as admin of the first relic contract | [`9d000b0a1b89168a9fc531a69c48d5e9f39e1b3ec98da79c5f9f03b951c4f9c6`](https://stellar.expert/explorer/testnet/tx/9d000b0a1b89168a9fc531a69c48d5e9f39e1b3ec98da79c5f9f03b951c4f9c6) | +| 03:54:37 | Set the game contract as admin of the second relic contract | [`0722fdcbb03d87d21f85f3fa2af4bc9c6071b5d275ce572cc8c6b3dc9039fb23`](https://stellar.expert/explorer/testnet/tx/0722fdcbb03d87d21f85f3fa2af4bc9c6071b5d275ce572cc8c6b3dc9039fb23) | +| 03:54:42 | Set the game contract as admin of the third relic contract | [`9e606376fd11b1394aea1e035e73462fd7712772a3bd4fc0a19c2cdc9406fcec`](https://stellar.expert/explorer/testnet/tx/9e606376fd11b1394aea1e035e73462fd7712772a3bd4fc0a19c2cdc9406fcec) | +| 03:54:47 | Lock the issuer: master key weight set to 0 | [`792dd0919d33938e0603b3ea3a4e6bac4a40de61f8680701dbbf25746447fc40`](https://stellar.expert/explorer/testnet/tx/792dd0919d33938e0603b3ea3a4e6bac4a40de61f8680701dbbf25746447fc40) | The issuer was checked on Horizon after the lock: one signer with weight 0 and thresholds at 0. A transaction signed with the issuer's own key was rejected with `TxBadAuth`. The admin of each relic contract was read back and is the game contract. -### End to end run on the new deployment +### End to end run (superseded) -`client/scripts/chain-smoke.ts` with a fresh account, three runs. All three transactions are `successful`. +`client/scripts/chain-smoke.ts` with a fresh account, three runs on the superseded contract. All three transactions are `successful`. | Ledger | What | Transaction | |---|---|---| -| 5150756 | `finish_run` with a defeat | `602d5a167cadfd23e81857282429f8ae41d85522a367613c9888edf6e6da89c0` | -| 5150764 | `finish_run` with a full clear, which earns delver 0 and mints relics | `28b5a324723890ff494860b0661a6083f5dcdf7f9f6bef2f1fbee8928fadf850` | -| 5150771 | `finish_run` with a full clear by the veteran delver, now at 2 clears | `a73fa14a5c92ef3fcc4fece8a7803322ef2170672cfcea6f0de9982d775bed2f` | +| 5150751 | `start_run`, run 1, the first on that deployment | [`d7673948db70b5a7760c68bfebdd56a0364bdf4f92a08af05d5ce43db84018cb`](https://stellar.expert/explorer/testnet/tx/d7673948db70b5a7760c68bfebdd56a0364bdf4f92a08af05d5ce43db84018cb) | +| 5150756 | `finish_run` with a defeat | [`602d5a167cadfd23e81857282429f8ae41d85522a367613c9888edf6e6da89c0`](https://stellar.expert/explorer/testnet/tx/602d5a167cadfd23e81857282429f8ae41d85522a367613c9888edf6e6da89c0) | +| 5150758 | `start_run`, run 2, seed 3840428440 | [`c78b0abb061635742142fed7eb7e3bfee2f7cd9f06ae8aa415acb2b8ebeb854e`](https://stellar.expert/explorer/testnet/tx/c78b0abb061635742142fed7eb7e3bfee2f7cd9f06ae8aa415acb2b8ebeb854e) | +| 5150764 | `finish_run` with a full clear, which earns delver 0 and mints relics (events `run_finished`, `hero_earned` and two `mint`) | [`28b5a324723890ff494860b0661a6083f5dcdf7f9f6bef2f1fbee8928fadf850`](https://stellar.expert/explorer/testnet/tx/28b5a324723890ff494860b0661a6083f5dcdf7f9f6bef2f1fbee8928fadf850) | +| 5150765 | `start_run`, run 3, with the veteran delver | [`ba7149af627a0412b6a209b487bcfda1f47db9d808db289347b3197712e66ab2`](https://stellar.expert/explorer/testnet/tx/ba7149af627a0412b6a209b487bcfda1f47db9d808db289347b3197712e66ab2) | +| 5150771 | `finish_run` with a full clear by the veteran delver, now at 2 clears | [`a73fa14a5c92ef3fcc4fece8a7803322ef2170672cfcea6f0de9982d775bed2f`](https://stellar.expert/explorer/testnet/tx/a73fa14a5c92ef3fcc4fece8a7803322ef2170672cfcea6f0de9982d775bed2f) | + +Every hash in this section was read back from Horizon on 2026-10-12 and reports `successful: true`. Fees and resources of the run transactions are in the README under "Measured costs". The contract was deployed by hand with the Stellar CLI in the order of the setup table, there is no deploy script in this repository, and later transactions on the contract, from play or testing, are visible in its event list on the explorer and are not listed here. ## Upgrade and renounce experiment -The admin paths were exercised on a separate, disposable contract so the game contract keeps its admin and its state. It was deployed from the same deployer, upgraded to a build that reports version 2 with the open run intact, and then frozen with `renounce_admin`. After that `admin()` returned nothing and `upgrade` failed with `Frozen`. +The admin paths were exercised on a separate, disposable contract so the game contract keeps its admin and its state. This ran before the redeploy and used the code of that time, the upgrade and renounce code has not changed since. It was deployed on the superseded deployment from its deployer (`GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ`), upgraded to a build that reports version 2 with the open run intact, and then frozen with `renounce_admin`. After that `admin()` returned nothing and `upgrade` failed with `Frozen`. | Item | Id | |---|---| -| Disposable contract (frozen) | `CDKXM55BE7NG5DTFKJWB7XZTCTAGUHUH6JQSCE6OO5IRF55N45AJTA6S` | +| Disposable contract (frozen) | [`CDKXM55BE7NG5DTFKJWB7XZTCTAGUHUH6JQSCE6OO5IRF55N45AJTA6S`](https://stellar.expert/explorer/testnet/contract/CDKXM55BE7NG5DTFKJWB7XZTCTAGUHUH6JQSCE6OO5IRF55N45AJTA6S) | | Time | Step | Transaction | |---|---|---| -| 03:56:17 | Deploy the disposable contract | `6c037979bae62269c8d6af763036cca601402028c70b332afd1cd89399b91ed8` | -| 03:56:27 | Start a run on it | `31eccf8a271dc353d8569b55b7834481c67c2bf285ad1c4086b010e475c553f4` | -| 03:56:32 | Upload the version 2 wasm | `51de58877faafcdf4cc994c9a60b5c570ae492f57a7c6c21ae38ec0081a448b0` | -| 03:56:37 | `upgrade` to the version 2 wasm | `b452ac33f126ed2ba3e4d91ce775ad7782319b21db0effd7a659a9ef6d18a5b3` | -| 03:56:52 | `renounce_admin` | `7f7e7eee7d640c53ff453a363d9195fadae62e11a88906a25b7165b1eb3e1cd5` | +| 03:56:17 | Deploy the disposable contract | [`6c037979bae62269c8d6af763036cca601402028c70b332afd1cd89399b91ed8`](https://stellar.expert/explorer/testnet/tx/6c037979bae62269c8d6af763036cca601402028c70b332afd1cd89399b91ed8) | +| 03:56:27 | Start a run on it | [`31eccf8a271dc353d8569b55b7834481c67c2bf285ad1c4086b010e475c553f4`](https://stellar.expert/explorer/testnet/tx/31eccf8a271dc353d8569b55b7834481c67c2bf285ad1c4086b010e475c553f4) | +| 03:56:32 | Upload the version 2 wasm | [`51de58877faafcdf4cc994c9a60b5c570ae492f57a7c6c21ae38ec0081a448b0`](https://stellar.expert/explorer/testnet/tx/51de58877faafcdf4cc994c9a60b5c570ae492f57a7c6c21ae38ec0081a448b0) | +| 03:56:37 | `upgrade` to the version 2 wasm | [`b452ac33f126ed2ba3e4d91ce775ad7782319b21db0effd7a659a9ef6d18a5b3`](https://stellar.expert/explorer/testnet/tx/b452ac33f126ed2ba3e4d91ce775ad7782319b21db0effd7a659a9ef6d18a5b3) | +| 03:56:52 | `renounce_admin` | [`7f7e7eee7d640c53ff453a363d9195fadae62e11a88906a25b7165b1eb3e1cd5`](https://stellar.expert/explorer/testnet/tx/7f7e7eee7d640c53ff453a363d9195fadae62e11a88906a25b7165b1eb3e1cd5) | -The game contract itself has not been upgraded or renounced. Its admin is still a single key, see `docs/threat-model.md`. +The game contract itself (the current one above) has not been upgraded or renounced. Its admin is still a single key, see `docs/threat-model.md`. -## Previous deployment +## First deployment (retired) -The first testnet deployment (contract `CDL7OO7MGCNMHZBF5QPJCMBLLY3ORR44TJ54ON44M25YUX6UZ7L46UZW`) is retired. Its relic issuer was never locked and the contract had no admin, so it could not be upgraded. The client no longer reads it, and nothing earned there migrates. +The first testnet deployment (contract [`CDL7OO7MGCNMHZBF5QPJCMBLLY3ORR44TJ54ON44M25YUX6UZ7L46UZW`](https://stellar.expert/explorer/testnet/contract/CDL7OO7MGCNMHZBF5QPJCMBLLY3ORR44TJ54ON44M25YUX6UZ7L46UZW)) is retired. Its relic issuer was never locked and the contract had no admin, so it could not be upgraded. The client no longer reads it, and nothing earned there migrates. diff --git a/docs/media/boss-fight.gif b/docs/media/boss-fight.gif new file mode 100644 index 0000000..720c728 Binary files /dev/null and b/docs/media/boss-fight.gif differ diff --git a/docs/media/delver-forged.jpg b/docs/media/delver-forged.jpg new file mode 100644 index 0000000..790ba6d Binary files /dev/null and b/docs/media/delver-forged.jpg differ diff --git a/docs/media/descent.jpg b/docs/media/descent.jpg new file mode 100644 index 0000000..5759ef5 Binary files /dev/null and b/docs/media/descent.jpg differ diff --git a/docs/media/floor-1-fight.jpg b/docs/media/floor-1-fight.jpg new file mode 100644 index 0000000..66f130f Binary files /dev/null and b/docs/media/floor-1-fight.jpg differ diff --git a/docs/media/floor-2.jpg b/docs/media/floor-2.jpg new file mode 100644 index 0000000..eb5c9f8 Binary files /dev/null and b/docs/media/floor-2.jpg differ diff --git a/docs/media/floor-3-boss.jpg b/docs/media/floor-3-boss.jpg new file mode 100644 index 0000000..4f3518c Binary files /dev/null and b/docs/media/floor-3-boss.jpg differ diff --git a/docs/media/poster.jpg b/docs/media/poster.jpg new file mode 100644 index 0000000..7693662 Binary files /dev/null and b/docs/media/poster.jpg differ diff --git a/docs/media/title.jpg b/docs/media/title.jpg new file mode 100644 index 0000000..df1ef03 Binary files /dev/null and b/docs/media/title.jpg differ diff --git a/docs/performance.md b/docs/performance.md index 03c59cb..9158803 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -24,7 +24,7 @@ The harness exits with code 1 when a budget below is exceeded. With `--json` the |---|---|---| | Transferred on a cold load | 3,954 KB | 937 KB | | Of which images | 3,787 KB | 760 KB | -| Of which JavaScript | 160 KB | 169 KB, with the Stellar SDK (135 KB gzip) loaded when idle | +| Of which JavaScript | 160 KB | 169 KB, with the Stellar SDK (135 KB gzip) loaded when idle (chunk `stellar-sdk`, 137 KB gzip as of 2026-10-12) | | Title usable | 385 ms | 73 ms | | First contentful paint | not captured | 44 ms | | Largest contentful paint | not captured | 96 ms | @@ -38,7 +38,7 @@ The harness exits with code 1 when a budget below is exceeded. With `--json` the ## What changed 1. **Art converted to WebP** with sizes matched to how each file is drawn (`client/scripts/optimize-art.sh`). The art folder went from 6.6 MB to 1.2 MB. -2. **The Stellar SDK loads when the browser is idle**, not before the title. The title no longer waits for 135 KB of cryptography it does not use yet. `client/src/stellar/lazy.ts` warms the chunk after first paint, so it is normally ready by the time a player presses Forge. +2. **The Stellar SDK loads when the browser is idle**, not before the title. The title no longer waits for 137 KB of cryptography it does not use yet. `client/src/stellar/lazy.ts` warms the chunk after first paint, so it is normally ready by the time a player presses Forge. The chunk is named `stellar-sdk` in `client/vite.config.ts`, and the Vite build warns above 700 KB, which is the size of that chunk by design. 3. **The main stylesheet is a normal ``**, not imported from a lazily loaded script. Loading it from script made the page paint unstyled and then jump, which measured a layout shift of 0.92 once everything else got fast. ## Budgets to keep diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..b83c346 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,63 @@ +# Deepmark roadmap + +This is where Deepmark is headed. It is a plan, not a promise, and dates will move. Each step is tied to a limit of the game today. + +## Where we are + +Deepmark is a turn-based dungeon crawler on Stellar testnet. When you extract, a smart contract replays your run from the seed it issued and works out the result itself, so a score, a rank or a relic cannot be claimed without playing for it. The game needs no wallet or sign-up. The known limits are listed in the [README](../README.md#limitations) and in the [threat model](threat-model.md): testnet only, an admin who can still upgrade the contract, delvers that are not yet standard NFTs, an account kept in your browser, no cap on relics, and no third party audit yet. + +## Fixes the threat model asks for + +These are bugs and gaps, not features. Several need a contract change, which means a redeploy and new ids. + +- Stop `transfer` from duplicating a delver id when it is sent to its own owner. +- Keep shared entries alive without a write, and have the client restore archived entries, so idle data does not break a call after about 120 days. +- Let a player claim relics a failed mint left behind, instead of losing them. +- Let a recipient refuse a delver, so nobody can fill another player's slots. +- Add friction against many accounts farming relics and boards: a mint budget, or a cost to start a run. The right design is not decided. +- A timelock on `upgrade`, or renounce the admin. + +## Next few weeks + +- Keep the game stable and fix what players report. +- Share cards and challenge links, so you can send someone a run to beat. +- Clearer first-run guidance. +- Decide the plan for the contract admin role, and publish it with a date. + +## Next few months + +- **Accounts that survive clearing your browser.** We are evaluating social login and passkey accounts. The provider is not decided, and we will only choose one that does not hold your keys on a server. +- **Sponsored fees,** so that a new player never needs to buy anything to play. +- **Mainnet.** Only after the contract admin is renounced or moved to a multisig, an independent review is done, and our own release checklist passes. +- **Standard NFT support for delvers.** We will follow the Stellar NFT standard once we have tested it with real wallets and explorers, and we will not claim support before then. +- **A first season:** a ladder with a fixed ruleset and verified results. + +## Later + +- **More floors and procedural runs.** This needs a committed seed so layouts cannot be exploited, and a fresh look at the on-chain cost of longer runs. A fixed three floor layout can be solved offline once the seed is known, which is also why prize ladders need this first. +- **Seasons with sponsored prizes.** Prizes would be funded by sponsors, never by players paying to enter, and paid out only for runs the contract has replayed. See [business-model.md](business-model.md). +- **Crews:** shared ladders for groups of players, with no treasury and no token. +- **An open replay kit.** The way Deepmark verifies runs could help other small games do the same. We plan to publish it as open source and see whether anyone wants it. +- **Optional cosmetics.** Looks only. They will never affect play, and they will stay clearly separate from delvers you earned. + +## Principles we will keep + +- The game has to be fun with the blockchain ignored. +- No speculative token. +- Nothing you can buy makes you stronger. +- Only a verified extraction can create a relic or an earned delver. +- No server ever holds your keys. +- We will say what is not done yet. + +## How we will know it works + +We will watch whether new players finish a run, whether they come back, and whether verified results stay clean. We will publish honest numbers when we have them, including the bad ones. + +## What is not yet verified + +Plans that rely on outside projects depend on testing we have not done yet. Until then they are plans, not features. + +- **Social login and passkey providers.** One provider, Cavos, says in its public documentation that it supports Stellar accounts, with hosted login and sponsored execution. That is a claim from its docs. We could not find a Stellar specific page, and we have not tried it, so who holds the key, how recovery works and what it costs are unknown. Passkey Kit and Blux are other options that we have only seen described, not tested. +- **The Stellar NFT standard.** It is a draft, and we have not tested delvers against any wallet or explorer. +- **Wallet and explorer support** for delvers or relics beyond what the explorer already shows for classic assets. +- **Sponsored fees on mainnet.** The testnet fees we measured are in the README. We have no mainnet numbers. diff --git a/docs/submission-checklist.md b/docs/submission-checklist.md index cbb856e..c097b6f 100644 --- a/docs/submission-checklist.md +++ b/docs/submission-checklist.md @@ -1,12 +1,12 @@ # Submission checklist -Status as of 2026-10-10. "Engineering" items are done in the repository and can be checked there. "Owner" items need the project owner's accounts, devices or a decision, and are left open on purpose. A line marked done says how it was checked. +Status as of 2026-10-12, after the evening redeploy of the testnet contract. "Engineering" items are done in the repository and can be checked there. "Owner" items need the project owner's accounts, devices or a decision, and are left open on purpose. A line marked done says how it was checked. ## Confirm first | Item | Owner | Status | |---|---|---| -| Confirm the submission deadline. `CLAUDE.md` gives 2026-10-05 16:00 Costa Rica time, and this checklist was written on 2026-10-10 | Owner | Open | +| Submission deadline | Owner | Confirmed by the owner: 2026-10-12. `CLAUDE.md` was updated to match | ## Repository and public text @@ -18,15 +18,20 @@ Status as of 2026-10-10. "Engineering" items are done in the repository and can | No em dash in public text | Engineering | Done for the working tree: the only occurrence is the rule quoted in `CLAUDE.md`. Git history contains older ones (9 outside `.claude/` on 2026-10-10) and has not been rewritten | | No AI sounding filler in public text | Owner | Open. Final read of README and docs by a person | | Licence file | Engineering | Done. Apache 2.0 in `LICENSE`, and the contract crate declares `Apache-2.0` | -| `.claude/` toolkit files in the tree | Owner | Open. 169 tracked files of third party skills. Decide whether they belong in the public repository. History also holds a merge titled `add-toolkit-claude` | -| Dependency licence audit | Owner | Open | -| Live link in the README | Owner | Open. The game has no public deployment yet, and the repository is private | +| `.claude/` toolkit files in the tree | Owner | Decided 2026-10-12: they stay in the public repository | +| Dependency licence audit | Owner | Not planned for this submission. Left open on purpose | +| Verify a run in 60 seconds, with a real seed and log | Engineering | Done, README. `verify_run` was run read only against the redeployed contract on 2026-10-12 with a run from that deployment and matches the `run_finished` event | +| Measured costs table from real transactions | Engineering | Done, README, from the six playtest runs on the redeployed contract. Testnet only, no mainnet figures | +| Roadmap, business model and a Spanish summary | Engineering | Done, `docs/roadmap.md`, `docs/business-model.md`, README. Third party claims are marked unverified | +| Stale facts fixed (test and vector counts, `verify_run` signature, two leaderboards, missing deploy script) | Engineering | Done 2026-10-12 | +| Threat model states sybil and solver farming, the admin without a timelock and the browser key as remaining, and the fixed items as fixed | Engineering | Done, `docs/threat-model.md`. The self transfer bug, the failed mint retry and the expiry of shared entries are fixed in the redeploy, with tests | +| Live link in the README | Engineering | Done, https://deepmark-dun.vercel.app. The deployed bundle carries the same contract and asset ids as `client/src/stellar/config.ts` | ## Secrets and visibility | Item | Owner | Status | |---|---|---| -| Secrets scan of the full history | Engineering | Done 2026-10-10 with gitleaks 8.30.1: 59 commits, no leaks. A second pattern search for Stellar secret keys (`S` followed by 55 base32 characters) over all revisions found none. Run both again at the freeze | +| Secrets scan of the full history | Engineering | Done 2026-10-12 with gitleaks 8.30.1: 70 commits on the current branch, no leaks. Earlier run 2026-10-10: 59 commits, no leaks. A second pattern search for Stellar secret keys (`S` followed by 55 base32 characters) over all revisions found none. Run both again at the freeze | | Secrets scan again at the T-1 freeze, on the final history | Owner | Open | | Visibility flip, after the freeze audit | Owner | Open. The repository is private | @@ -34,13 +39,14 @@ Status as of 2026-10-10. "Engineering" items are done in the repository and can | Item | Owner | Status | |---|---|---| -| `cargo test` | Engineering | Done 2026-10-11: 71 tests and 600 golden vectors pass | +| `cargo test` | Engineering | Done, re-run 2026-10-12: 80 tests and 600 golden vectors pass (`cargo test`) | | Golden vectors current against the client | Engineering | Checked by the client workflow on every change | -| Testnet deployment, ids and transactions published | Engineering | Done, in the README and `docs/deployments.md` | -| Issuer account locked | Engineering | Done on the 2026-10-11 testnet deployment: one signer with weight 0, and a transaction signed with its key is rejected | -| Admin renounced or moved to a multisig | Owner | Open. The testnet admin is a single key and can upgrade the contract. Decide before mainnet and before the final submission | +| Testnet deployment, ids and transactions published | Engineering | Done 2026-10-12, in the README and `docs/deployments.md`: the redeployed game contract `CDQDI2SN...H7RX` (wasm sha256 `db721cb9...0d07`, 28,107 bytes, matches `stellar contract fetch`), all 13 setup transactions and the 12 playtest transactions, each read back from Horizon as successful. The earlier deployments are listed as superseded | +| Real transactions through the real client on the redeployed contract | Engineering | Done 2026-10-12: six runs (early extraction, rare extraction, death, two full clears with a delver each, a veteran run), supply equals mints, `verify_run` matches, costs measured. Evidence is in the README and `docs/deployments.md` | +| Issuer account locked | Engineering | Done on the current testnet deployment (lock transaction 2026-10-12 18:30 UTC): one signer with weight 0 and thresholds 0, re-read on Horizon after the playtest. A signed attempt with the issuer key was not repeated on this issuer | +| Admin renounced or moved to a multisig | Owner | Open. The testnet admin `GCJWADMY...2WEX` is a single key and can upgrade the contract. The owner decides whether to call `renounce_admin` (it freezes the contract for good, so only after the last fix) before the final submission | | Mainnet or an explicit statement that there is none | Owner | Open. Needs a funded deployer and presence for the transactions | -| Standard NFT interface or an explicit list of deviations | Engineering | Done as a list of deviations, `docs/architecture/nft-standard.md` | +| Standard NFT interface or an explicit list of deviations | Engineering | Done as a list of deviations, `docs/architecture/delvers-and-nft-standard.md` | | A delver visible in an explorer | Engineering | Open. The contract does not yet expose the metadata calls and events an explorer looks for | ## Client quality @@ -50,11 +56,20 @@ Status as of 2026-10-10. "Engineering" items are done in the repository and can | Typecheck and build | Engineering | Checked by the client workflow | | Performance budgets enforced | Engineering | Done, `.github/workflows/perf.yml` and `docs/performance.md` | | Accessibility checks and the manual keyboard walkthrough | Engineering | Done, `.github/workflows/a11y.yml` and `docs/accessibility.md`. No screen reader pass yet | -| Clean browser verification on a laptop | Owner | Open. A fresh profile, from the title to a verified result | -| Clean browser verification on a phone, iOS Safari and a mid range Android | Owner | Open. Needs the devices | +| Clean browser verification on a laptop | Owner | Partly done 2026-10-12. Headless Chrome, fresh profile, 1280x800 against the live URL: loads in under 2 seconds, the account is created and the relic trustlines are added behind the title, no console errors, and requests go only to the site, Friendbot, Soroban RPC and Horizon. A full run to a verified result in a real browser is still open | +| Clean browser verification on a phone, iOS Safari and a mid range Android | Owner | Partly done 2026-10-12. The same check at 390x844 with touch emulation passes. Real devices are still open | ## Media | Item | Owner | Status | |---|---|---| -| Trailer, 30 and 90 second cuts, and README media | Owner | Open. Tracked in its own issue | +| Trailer, 30 and 90 second cuts, share card, and README media | Owner | Done 2026-10-12. Unlisted on YouTube and linked from the README: [30 s](https://youtu.be/X5GZSp7EFE8), [90 s](https://youtu.be/F-gPDj1Yt5A), [share card](https://youtu.be/uJX_Ii3xAXE) | + +## Owner actions still open on 2026-10-12 + +- Submit the project on Stellar Passport. +- Record the pitch video (see Media above). +- Test a full run on a real iPhone and a real mid range Android phone. +- Decide on `renounce_admin` for the testnet contract. +- Final read of README and docs for filler, the secrets scan and the visibility flip at the freeze. +- Playtest issues to triage: simultaneous new player starts collide on the player index (2 of 3 failed in the playtest, the client now retries, a keyed registry is a later contract fix), and the veteran result screen shows the new delver copy. diff --git a/docs/threat-model.md b/docs/threat-model.md index 9c0bf01..2c854a2 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,6 +1,6 @@ # Threat model -The attacks considered against the Deepmark contract, what stops each one, and what is left. Every "stops" entry names the code path and the test that covers it, so it can be checked: `contracts/deepmark-contract/src/lib.rs` for the entrypoints and `src/test.rs` for the tests. The suite is run with `cargo test` and was green on 2026-10-10: 63 unit and entrypoint tests plus the 600 cross-language vectors. +The attacks considered against the Deepmark contract, what stops each one, and what is left. Every "stops" entry names the code path and the test that covers it, so it can be checked: `contracts/deepmark-contract/src/lib.rs` for the entrypoints and `src/test.rs` for the tests. The suite is run with `cargo test` and was green on 2026-10-12: 80 unit and entrypoint tests plus the 600 cross-language vectors. A test passing means the listed defence works as written. It does not mean the list of attacks is complete, and the "Remains" entries are the part to read carefully. ## What is protected @@ -9,7 +9,7 @@ The attacks considered against the Deepmark contract, what stops each one, and w - A **delver**: a character that exists because a fresh one cleared all three floors. - A **player's access**: nobody else can start, finish or move things for an address. -There is no money in the contract. It does have an admin who can upgrade it, which is the largest trust assumption and is covered under "The admin" below. Apart from the admin, the worst realistic outcome of any attack below is a false leaderboard row or a relic that was not earned. That sets how much defence is proportionate. +There is no money in the contract. It does have an admin who can upgrade it, which is the largest trust assumption and is covered under "The admin" below. Apart from the admin, the worst realistic outcome of any attack below is a leaderboard row or a relic that a bot produced rather than a distinct person, or a lost relic. That sets how much defence is proportionate on testnet. It would not be enough for mainnet value. ## Attacks @@ -35,7 +35,7 @@ There is no money in the contract. It does have an admin who can upgrade it, whi **Stops it, partly.** The client cannot choose the seed. It is derived on chain from the ledger sequence, the ledger time, the player's run counter and the address (`derive_seed`). Starting again costs a transaction and a cooldown of 6 ledgers, about 30 seconds (`START_COOLDOWN_LEDGERS`), and starting while a run is open counts that run as abandoned on the profile (`forfeits`). Test: `starting_again_too_soon_is_rejected_and_later_it_forfeits_the_open_run`. -**Remains.** A patient player can keep starting runs and only finish the ones with a good seed. The cost is time and fees, and the abandoned counter is visible, but the limit is economic, not absolute. The finish must come at least `actions / 16` ledgers after the start (`TooFast`, `ACTIONS_PER_LEDGER`), so a bot cannot submit a plan instantly, but it can plan during that wait. Test: `a_run_cannot_be_submitted_faster_than_it_could_be_played`. +**Remains.** A patient player can keep starting runs and only finish the ones with a good seed. The cooldown and the abandoned counter are per address, so a player with several funded addresses starts runs on all of them in the same ledger, solves the seeds offline (the game is deterministic and the seed is public in the `run_started` event), finishes only the best run, and leaves the discarded runs as abandoned on throwaway addresses. On testnet Friendbot funds those addresses for free, so the real limit today is effort, not cost. The seed is also predictable per address: it is a hash of public inputs (ledger sequence and time, the run counter and the address), so someone can generate addresses offline and fund only the ones that map to an easy seed. The finish must come at least `actions / 16` ledgers after the start (`TooFast`, `ACTIONS_PER_LEDGER`), so a bot cannot submit a plan instantly, but it can plan during that wait. Test: `a_run_cannot_be_submitted_faster_than_it_could_be_played`. ### Lockout @@ -43,20 +43,25 @@ There is no money in the contract. It does have an admin who can upgrade it, whi **Stops it.** Both writing entrypoints call `require_auth` on the player, so nobody can start, finish or give away for an address that did not sign. Test: `nobody_can_act_for_an_address_that_did_not_sign`. A failed replay returns an error before the open run is removed, and the transaction reverts, so a bad or partial log never consumes or blocks a run. An open run that is never finished expires after two days (`RUN_TTL`), and a player can start again after the cooldown, which forfeits it. A veteran that was given away in the middle of a run cannot be finished by the old owner, who can start a new run. Test: `a_veteran_given_away_mid_run_cannot_finish_for_the_old_owner`. +**Fixed in the 2026-10-12 redeploy.** `transfer(a, a, id)` used to save the id twice in the owner's list. It now returns early as an authorized no-op, with no write and no event, and still needs the owner's signature. Tests: `giving_a_delver_to_its_own_owner_changes_nothing`, `a_self_transfer_still_needs_the_owner`, `transfers_keep_every_delver_in_exactly_one_list` (`src/test_safety.rs`). Entries that expire while idle are now renewed where the game already writes: `start_run` extends the player counter, the delver counter and both leaderboards, and the owner's delver and its index when a veteran starts a run. Tests: `starting_a_run_keeps_the_shared_entries_alive`, `using_a_veteran_keeps_it_and_its_owner_index_alive`, `keeping_entries_alive_costs_no_rent_while_they_are_fresh`. + **Remains.** -- **Delver slot griefing.** An account can hold at most 64 delvers (`MAX_HEROES_PER_OWNER`), and `transfer` needs only the sender's signature, so someone who owns 64 delvers could send them to a victim and fill that victim's slots. After that a clear by the victim still counts and still mints relics, but `earn_or_advance` returns no new delver. The attacker needs 64 verified full clears first, which is a high price. It is a known gap, and the fix is to let a recipient refuse or to remove the cap. -- **A lost log.** If a player closes the tab before the log is sent, the run is lost and later counted as abandoned. Persisting the unsent log and resuming is tracked in the issue tracker. -- **The browser keypair.** The player's key is kept in that browser's `localStorage`. Clearing storage loses the identity and its records, and script running on the page could read it. A passkey account is the planned replacement, and no custodial key is held by any server. +- **Delver slot griefing.** An account can hold at most 64 delvers (`MAX_HEROES_PER_OWNER`), and `transfer` needs only the sender's signature, so someone who owns 64 delvers could send them to a victim and fill that victim's slots. After that a clear by the victim still counts and still mints relics, but `earn_or_advance` returns no new delver. The attacker needs 64 verified full clears first. That is 64 runs of effort for a human, but a solver bot with several accounts can produce them quickly, so this is not a strong barrier. It is a known gap, and the fix is to let a recipient refuse or to remove the cap. +- **Idle entries of inactive players can still be archived.** `start_run` now renews the shared entries and the delver in use (see above), and a player's own record and the player index entries are extended when written. A player who does not return for about 120 days can have those archived, and calls that need them fail until someone restores them. Nothing is lost, but the client does not restore automatically. The contract instance is extended on every `start_run` and `finish_run`, and at deploy time its instance and wasm were extended too ([instance](https://stellar.expert/explorer/testnet/tx/3a174eec310202b9124525f8099e6228a8042c310ce08838b7a2b47a0609d4ba), [wasm](https://stellar.expert/explorer/testnet/tx/ae8a038a8470479fc2fa603dee70d718b7f75838bed101d13a09739661c389fb)). +- **New player registration is serialized.** A new player is appended to a sequential index (`PlayerAt(i)`), so several brand new players whose `start_run` lands in the same ledger simulate the same next index, and the later transactions fail with `storage exceeded_limit` on the key `PlayerAt N`. In the playtest, 3 fresh accounts starting together gave 2 failures out of 3. This is a throughput limit of new player registration, about one per ledger (about 5 seconds). The client detects a failed `start_run` (`client/src/stellar/txOutcome.ts`), retries up to 4 times with jittered backoff and re-simulates, and a local re-run had 6 of 6 starts succeed. A returning player is never affected. A registry keyed by address would remove the limit in a later contract version. +- **A lost log.** If a player closes the tab before the log is sent, the run is lost and later counted as abandoned. The client does not yet persist an unsent log across a closed tab. +- **The browser keypair.** The player's key is kept in that browser's `localStorage`. Clearing storage loses the identity and its records, and script running on the page could read it. The live site sent no content security policy header when checked with `curl -I` on 2026-10-12, so a script injection or a bad dependency would not be stopped by the browser. A passkey account is the planned replacement, and no custodial key is held by any server. ### Minting without play *Obtain relics or a delver without a verified run.* -**Stops it.** The only code that mints relics is `mint_relics`, called from `finish_run` with the amounts the replay derived and only for an extraction. The only code that creates a delver is `earn_or_advance`, also inside `finish_run`, and only for a fresh delver that extracted from floor 3. The relics are Stellar Asset Contracts whose admin is the game contract, set by `scripts/deploy-testnet.sh`. Tests: `a_banked_relic_is_minted_as_a_stellar_asset`, `a_run_without_relics_mints_nothing`, `a_run_that_does_not_clear_all_three_floors_earns_nothing`. A mint failure, for example a missing trustline, is isolated with `try_mint`, so the run is still recorded. Test: `a_failed_mint_never_loses_the_verified_run`. +**Stops it.** The only code that mints relics is `mint_relics`, called from `finish_run` with the amounts the replay derived and only for an extraction. The only code that creates a delver is `earn_or_advance`, also inside `finish_run`, and only for a fresh delver that extracted from floor 3. The relics are Stellar Asset Contracts whose admin is the game contract, set in three transactions recorded in [`deployments.md`](deployments.md) and checkable with the `admin` call shown in the README. Tests: `a_banked_relic_is_minted_as_a_stellar_asset`, `a_run_without_relics_mints_nothing`, `a_run_that_does_not_clear_all_three_floors_earns_nothing`. A mint failure, for example a missing trustline, makes `finish_run` return `MintFailed` (14) and reverts every write, so the run is not consumed, nothing is recorded and the player can fix the cause and finish again. Test: `a_failed_mint_fails_the_finish_and_keeps_the_run_open`. Missing relic configuration returns `NotInitialized` (13) instead of a panic. Test: `a_missing_relic_configuration_is_a_typed_error`. **Remains.** -- **The issuer is locked, the admin is not.** The relic issuer `GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK` has a single signer with weight 0 and thresholds at 0, checked on Horizon on 2026-10-11, and a transaction signed with its own key is rejected with `TxBadAuth`. So the only minter is the game contract. While the contract admin exists, the admin can upgrade the contract to code that mints without a replay. See "The admin". -- **A missed mint is not retried.** If `try_mint` fails, the run reports `minted: false` and the relics are not issued later. +- **The issuer is locked, the admin is not.** The relic issuer [`GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q`](https://stellar.expert/explorer/testnet/account/GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q) has a single signer with weight 0 and thresholds at 0 (read on Horizon on 2026-10-12 after the redeploy, set by [this transaction](https://stellar.expert/explorer/testnet/tx/ddae4f0c5f28ff1bc08af97949dd85861fe3b087eefb67b1e4893b354c514e28)), so nobody holds a key that can sign for it. (On the superseded deployment a transaction signed with the issuer key was also rejected with `TxBadAuth`. That test was not repeated on this issuer.) So the only minter is the game contract. While the contract admin exists, the admin can upgrade the contract to code that mints without a replay. See "The admin". +- **A rejected mint blocks the finish until the cause is fixed.** The player cannot lose relics to a missing trustline, but an account that can never receive a relic (for example one that removed its trustline and cannot restore it) cannot finish a run that banked that relic. The client adds the three trustlines at account setup. +- **Relics have no supply cap.** Nothing limits mints per address, per day or in total. The scarcity is that a verified run is needed, so a bot that solves seeds can mint without limit (see "Many accounts"). Calling relics scarce is only fair while testnet runs are the unit of effort. - **Delvers move freely.** A delver can be given to anyone, which is intended. It means a delver does not prove the current holder played it, only that its first clear was verified. ### Leaderboard flooding @@ -66,8 +71,9 @@ There is no money in the contract. It does have an admin who can upgrade it, whi **Stops it.** Each sorted list holds the best 100 entries with one row per player, and only a run that beats that player's own best is inserted (`update_top`). A worse run does not replace a better one. Tests: `the_leaderboard_ranks_by_the_verified_result_and_lists_each_player_once`, `a_worse_run_does_not_replace_a_better_leaderboard_entry`, `the_sorted_top_is_bounded_but_players_are_not`. Fresh delvers and veterans have separate lists. The list of all players is paged at 50 per call (`players_are_paged_without_a_cap_on_how_many_exist`), so reading it has a bounded cost. **Remains.** -- **Many accounts.** A person with many funded accounts can occupy rows, each with a real verified run. On testnet, Friendbot funds accounts for free, so the cost is only the work of playing. The board proves that a run was verified. It does not prove that a distinct person played it. +- **Many accounts.** A person with many funded accounts can occupy rows, each with a real verified run. On testnet, Friendbot funds accounts for free, so the cost is the work of playing, and for a bot that work is solving a seed offline, which takes little time. The same farm can fill both boards, mint relics without bound, produce delvers to dump on a victim, and pass veterans between its accounts. The board proves that a run was verified. It does not prove that a distinct person played it. The 2026-10-12 playtest is an example: about 19 throwaway accounts were created in a few hours with Friendbot, so the trustline counts on the relic assets count test accounts, not players. Closing this needs a cost or an identity layer (a start fee, sponsored fees with per-account limits, or a mint budget), which is not built. - **Index growth.** The player index is not capped. Each entry costs its creator a funded account and the fees of a start, and the storage lifetime is extended in the same write. +- **Unmoderated names.** A delver name is any 3 to 16 character ASCII alphanumeric string, so a board can show offensive or impersonating names. They are rendered as text, so this is not a script injection risk. - **Exact ties.** Two runs with the same ranking key (extraction, score, floor, HP left, actions) are ordered by who got there first, because `update_top` inserts a new entry only above a strictly lower key. ### The admin @@ -77,12 +83,14 @@ There is no money in the contract. It does have an admin who can upgrade it, whi **Stops it.** `upgrade`, `set_admin` and `renounce_admin` all require the admin's signature, and handing the role over needs the signatures of both the current and the new admin, so a mistyped address cannot take it. Every change emits an event. Tests in `src/admin.rs`: `nobody_without_the_admin_signature_can_change_anything`, `the_admin_can_upgrade_and_the_swap_is_announced`, `handing_over_the_role_needs_both_signatures`, `a_new_admin_that_did_not_sign_cannot_be_installed`, `renouncing_freezes_the_contract_for_good`. After `renounce_admin` the contract rejects `upgrade` and `set_admin` with `Frozen`, which was also checked on a throwaway testnet deployment. **Remains.** -- **While the role exists, players trust the admin.** The admin can upgrade to code that mints relics without a replay, rewrites the leaderboard or moves delvers. Replay verification protects players from forged logs, not from the admin. The testnet admin is `GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ`, a single key. +- **While the role exists, players trust the admin.** The admin can upgrade to code that mints relics without a replay, rewrites the leaderboard or moves delvers. Replay verification protects players from forged logs, not from the admin. The testnet admin is `GCJWADMYI4N42EDLCRAYGORAHPXLRSIZVQSN75H552H2GWKF3SXL2WEX`, a single key. +- **No timelock.** `upgrade` takes effect in the same transaction it is signed in. Players get no warning window, only the `upgraded` event afterwards. - **The way out is planned, not done.** Before any mainnet release the role is renounced, or moved with `set_admin` to a multisig account so no single key can change the code. - **Storage is not versioned.** An upgrade has to keep every stored key and struct readable. `version()` tells a client which release is running. ## What is not covered -- No third party audit. The review that exists is the test suite above, a mutation check on the vectors, and live testnet attempts to replay a log, forge a floor 3 run and restart too soon, all rejected, as recorded in the contract README. -- Testnet only. A mainnet deployment needs the checklist in the internal repository, a locked issuer, and a decision on how players are funded. +- No third party audit. The review that exists is the test suite above, a mutation check on the vectors, and live testnet attempts to replay a log, forge a floor 3 run and restart too soon, all rejected, as recorded in the contract README. Those attempts are not transactions linked here, so treat them as the author's report. The deployed transactions that can be checked are in [`deployments.md`](deployments.md). +- Testnet only. A mainnet deployment needs a release checklist, a locked issuer, a renounced or multisig admin, an independent review and a decision on how players are funded. None of that is done. +- A malicious or faulty RPC could return a ticket that differs from the one on chain. The client did not range check it when reviewed on 2026-10-12, so the effect would be a run that `finish_run` rejects or scores differently, not a forged win. - The client is not a trust boundary. Everything it computes is advisory, and the contract replays it again. diff --git a/trailer/.gitignore b/trailer/.gitignore new file mode 100644 index 0000000..bb58da0 --- /dev/null +++ b/trailer/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +out/ +# Recorded from the game and rebuilt by `bun run capture`. +public/footage/ +public/art/ +public/audio/ +public/fx/ diff --git a/trailer/README.md b/trailer/README.md new file mode 100644 index 0000000..7239c8c --- /dev/null +++ b/trailer/README.md @@ -0,0 +1,59 @@ +# Deepmark trailer + +The launch film, built with [Remotion](https://www.remotion.dev). This workspace is not part of the shipped client: nothing in `client/` or `contracts/` depends on it. + +| Output | File | Notes | +|---|---|---| +| 30 second trailer | `out/deepmark-trailer-30s.mp4` | For posts. Under 15 MB. Published: https://youtu.be/X5GZSp7EFE8 | +| 90 second cut | `out/deepmark-trailer-90s.mp4` | Adds what the contract verifies and why a result cannot be forged. Published: https://youtu.be/F-gPDj1Yt5A | +| Animated share card | `out/deepmark-share-card.mp4` | 1200 by 630, the motion version of the card the game saves. | +| README media | `../docs/media/` | Screenshots of the real game and a boss fight GIF. Committed. | + +## One command + +```sh +bun install +bun run build:all +``` + +That syncs the painted art from the client, synthesises the score, builds and films the production game, renders both cuts and the card, and cuts the README media. Run it from this folder. It needs Bun, Google Chrome (set `CHROME_PATH` if it is not in the default macOS location) and `ffmpeg`. + +Individual steps: `bun run sync`, `audio`, `capture`, `render:30`, `render:90`, `render:card`, `media`. `bun run studio` opens Remotion Studio. + +## Where the footage comes from + +Gameplay is the real game, not an animation of it. `capture/record.ts` builds the client, serves the production build, blocks the Stellar hosts so the run is a deterministic practice run, and plays a chosen seed through the real keyboard input while recording at 3840 by 2160. + +The run on screen is not arbitrary. `capture/plan.ts` searches seeds with the game's own simulation for a winning extraction that ends on exactly what the game's sealed result card shows in preview (floor 3, 2 relics, score 4, 1 HP left). The played footage and the card tell one story. The same action log is what the explainer graphics draw: the 51 bytes on screen are the bytes the run was played with. + +While recording, the script writes a cue log (`src/generated/run-cues.json`) with what each input did and where the delver and guard stood. The edit reads it, so cuts land on real events and the camera follows the delver. Re-recording moves the edit with it. + +What is real and what is a design preview: + +- **Real**: the title, the delver forge, all three floors, the fights, the boss, the extraction. They are the production build. +- **Game preview screens**: the sealed result card and the vault come from the game's own `?preview=` screens, which run without a chain. Their numbers match the filmed run, but the rank and the seal are not a live transaction. Do not describe these shots as a recorded transaction. + +## Structure + +``` +capture/ plays and records the game, searches for the filmed run +audio/ the score, synthesised from numbers (no samples, nothing to license) +scripts/ asset sync and README media +src/ + timeline.ts scene boundaries and where each shot starts in the recording + score-spec.ts what the score does, derived from the same timeline + camera.ts the follow camera, a pure function of recording time + scenes/ hook, boss banner, choice, end card + scenes/explain/ the five chapters of the 90 second cut + compositions/ Trailer30, Trailer90, ShareCardAnimated +``` + +Timing lives in `src/timeline.ts` and the score reads the same numbers, so moving a scene and re-running `bun run audio` keeps picture and sound together. + +## Rules the cuts follow + +- The first five seconds are a hook with no logo and no text. +- The Stellar explanation comes after the game, never before it. +- Every animation is a function of the frame number. Nothing uses CSS transitions, so every frame renders the same on any machine. + +Recordings, art copies and renders are git ignored. They are rebuilt by the command above. diff --git a/trailer/audio/synth.ts b/trailer/audio/synth.ts new file mode 100644 index 0000000..8b182f6 --- /dev/null +++ b/trailer/audio/synth.ts @@ -0,0 +1,281 @@ +/** + * Deterministic score synthesiser. No samples and no licensed music: pads, + * a heartbeat, impacts and bells are all computed from numbers, so the audio + * renders identically on every machine and ships with nothing to attribute. + * + * bun run audio + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { SCORE_30, SCORE_90, type Chord, type Cue, type ScoreSpec } from '../src/score-spec'; + +const SR = 44_100; +const OUT = new URL('../public/audio/', import.meta.url).pathname; + +type Channels = [Float32Array, Float32Array]; + +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const midi = (n: number): number => 440 * 2 ** ((n - 69) / 12); +const clamp01 = (x: number): number => Math.min(1, Math.max(0, x)); +const smooth = (x: number): number => x * x * (3 - 2 * x); + +function level(points: [number, number][], t: number): number { + const first = points[0]; + const last = points[points.length - 1]; + if (!first || !last) return 0; + if (t <= first[0]) return first[1]; + if (t >= last[0]) return last[1]; + for (let i = 1; i < points.length; i += 1) { + const a = points[i - 1] as [number, number]; + const b = points[i] as [number, number]; + if (t <= b[0]) return a[1] + (b[1] - a[1]) * smooth((t - a[0]) / (b[0] - a[0])); + } + return last[1]; +} + +function add(ch: Channels, start: number, length: number, fn: (t: number, i: number) => number, pan = 0): void { + const i0 = Math.max(0, Math.floor(start * SR)); + const n = Math.min(Math.floor(length * SR), ch[0].length - i0); + const l = Math.cos(((pan + 1) * Math.PI) / 4); + const r = Math.sin(((pan + 1) * Math.PI) / 4); + for (let i = 0; i < n; i += 1) { + const v = fn(i / SR, i); + (ch[0] as Float32Array)[i0 + i] = (ch[0][i0 + i] as number) + v * l; + (ch[1] as Float32Array)[i0 + i] = (ch[1][i0 + i] as number) + v * r; + } +} + +/** Sub drone and a dark noise bed that follow the spec's level curve. */ +function bed(ch: Channels, spec: ScoreSpec): void { + const rand = rng(11); + let lpL = 0; + let lpR = 0; + for (let i = 0; i < ch[0].length; i += 1) { + const t = i / SR; + const lv = level(spec.bed, t); + const sub = Math.sin(2 * Math.PI * 36.71 * t) * 0.26 + Math.sin(2 * Math.PI * 73.42 * t + Math.sin(t * 0.3)) * 0.07; + lpL += (rand() * 2 - 1 - lpL) * 0.012; + lpR += (rand() * 2 - 1 - lpR) * 0.012; + const wash = 0.5 + 0.5 * Math.sin(t * 0.43); + (ch[0] as Float32Array)[i] = (ch[0][i] as number) + lv * (sub + lpL * (2.2 + wash * 1.6)); + (ch[1] as Float32Array)[i] = (ch[1][i] as number) + lv * (sub + lpR * (2.2 + wash * 1.6)); + } +} + +function pads(ch: Channels, chords: Chord[], seconds: number): void { + chords.forEach((chord, ci) => { + const end = chords[ci + 1]?.t ?? seconds; + const length = end - chord.t + 2.4; + chord.notes.forEach((note, ni) => { + const f = midi(note); + const pan = (ni - 1.5) * 0.4; + for (const detune of [-0.18, 0.21]) { + add( + ch, + chord.t - 0.8, + length, + (t) => { + const attack = smooth(clamp01(t / 2.6)); + const release = 1 - smooth(clamp01((t - (end - chord.t + 0.8)) / 2.4)); + const trem = 0.82 + 0.18 * Math.sin(t * (0.35 + ni * 0.07)); + const w = 2 * Math.PI * f * (1 + detune * 0.003) * t; + const tone = Math.sin(w) + 0.38 * Math.sin(2 * w) + 0.14 * Math.sin(3 * w + 1) + 0.05 * Math.sin(5 * w); + return tone * attack * release * trem * chord.gain * 0.026; + }, + pan, + ); + } + }); + }); +} + +function thump(ch: Channels, t: number, gain: number): void { + add(ch, t, 0.5, (x) => Math.sin(2 * Math.PI * (38 + 36 * Math.exp(-x * 24)) * x) * Math.exp(-x * 10) * gain * 0.95); +} + +function heartbeat(ch: Channels, spec: ScoreSpec): void { + for (const hb of spec.heartbeats) { + let t = hb.from; + while (t < hb.to) { + const p = clamp01((t - hb.from) / (hb.to - hb.from)); + const bpm = hb.bpmFrom + (hb.bpmTo - hb.bpmFrom) * p * p; + const g = hb.gain * (0.5 + 0.5 * p) * smooth(clamp01((t - hb.from) / 1.2)); + thump(ch, t, g); + thump(ch, t + 0.27 * (60 / bpm) / (60 / 70), g * 0.62); + t += 60 / bpm; + } + } +} + +function boom(ch: Channels, t: number, gain: number): void { + const rand = rng(Math.floor(t * 1000)); + add(ch, t, 3.2, (x) => Math.sin(2 * Math.PI * (28 + 70 * Math.exp(-x * 5)) * x) * Math.exp(-x * 1.5) * gain * 0.9); + let lp = 0; + add(ch, t, 1.4, (x) => { + lp += (rand() * 2 - 1 - lp) * 0.08; + return lp * Math.exp(-x * 3.2) * gain * 1.8; + }); +} + +function hit(ch: Channels, t: number, gain: number): void { + const rand = rng(Math.floor(t * 977)); + let band = 0; + let low = 0; + add(ch, t, 0.35, (x) => { + const n = rand() * 2 - 1; + low += (n - low) * 0.5; + band += (low - band) * 0.25; + return (n - low) * 0.5 * Math.exp(-x * 26) * gain * 0.55 + band * Math.exp(-x * 14) * gain * 0.7; + }); + thump(ch, t, gain * 0.8); +} + +function bell(ch: Channels, t: number, f0: number, gain: number, pan: number): void { + const partials: [number, number, number][] = [ + [1, 1, 1.9], + [2.76, 0.5, 2.6], + [5.4, 0.28, 3.8], + [8.93, 0.14, 5.2], + ]; + add( + ch, + t, + 4, + (x) => { + let v = 0; + for (const [ratio, amp, decay] of partials) v += Math.sin(2 * Math.PI * f0 * ratio * x) * amp * Math.exp(-x * decay); + return v * gain * 0.13 * smooth(clamp01(x / 0.004)); + }, + pan, + ); +} + +function riser(ch: Channels, t: number, length: number): void { + const rand = rng(Math.floor(t * 313)); + let lp = 0; + add(ch, t, length, (x) => { + const p = clamp01(x / length); + lp += (rand() * 2 - 1 - lp) * (0.05 + 0.5 * p * p); + const sweep = Math.sin(2 * Math.PI * (160 + 1500 * p * p) * x); + return (lp * 0.9 + sweep * 0.12) * p * p * 0.55; + }); +} + +/** A dry click, the sound of one byte being written or read. */ +function tick(ch: Channels, t: number, gain: number): void { + const rand = rng(Math.floor(t * 7919)); + let low = 0; + add(ch, t, 0.06, (x) => { + const n = rand() * 2 - 1; + low += (n - low) * 0.35; + return (n - low) * Math.exp(-x * 90) * gain * 0.16 + Math.sin(2 * Math.PI * 2600 * x) * Math.exp(-x * 120) * gain * 0.05; + }, (rand() - 0.5) * 0.6); +} + +function cue(ch: Channels, c: Cue): void { + const gain = c.gain ?? 1; + if (c.kind === 'tick') { + tick(ch, c.t, gain); + return; + } + if (c.kind === 'hit') hit(ch, c.t, gain); + else if (c.kind === 'boom') boom(ch, c.t, gain); + else if (c.kind === 'riser') riser(ch, c.t, c.value ?? 3); + else if (c.kind === 'chime') bell(ch, c.t, c.value ?? 880, gain, 0.2); + else { + // The seal: a low struck bell and its fifth, the moment the contract agrees. + const f = c.value ?? 587; + boom(ch, c.t, 0.55 * gain); + bell(ch, c.t, f, 1.1 * gain, -0.15); + bell(ch, c.t + 0.09, f * 1.5, 0.8 * gain, 0.25); + bell(ch, c.t + 0.2, f * 2, 0.6 * gain, 0); + } +} + +/** A few feedback combs, enough tail to make the bells and booms sit in a stone room. */ +function room(ch: Channels): void { + const taps: [number, number][] = [ + [0.0297, 0.52], + [0.0371, 0.48], + [0.0411, 0.46], + [0.0437, 0.44], + [0.131, 0.34], + ]; + const dry: Channels = [Float32Array.from(ch[0]), Float32Array.from(ch[1])]; + for (let c = 0; c < 2; c += 1) { + const out = ch[c] as Float32Array; + const src = dry[c] as Float32Array; + for (const [delay, feedback] of taps) { + const d = Math.floor(delay * SR * (c === 0 ? 1 : 1.07)); + const line = new Float32Array(out.length); + for (let i = d; i < out.length; i += 1) line[i] = (src[i - d] as number) * 0.5 + (line[i - d] as number) * feedback; + for (let i = 0; i < out.length; i += 1) out[i] = (out[i] as number) + (line[i] as number) * 0.28; + } + } +} + +function master(ch: Channels, spec: ScoreSpec): void { + let peak = 0; + for (let i = 0; i < ch[0].length; i += 1) { + const t = i / SR; + const fade = i < SR * 0.05 ? i / (SR * 0.05) : 1; + const out = 1 - smooth(clamp01((t - (spec.seconds - spec.fadeOut)) / spec.fadeOut)); + for (const c of ch) { + const v = Math.tanh((c[i] as number) * 1.15) * fade * out; + (c as Float32Array)[i] = v; + peak = Math.max(peak, Math.abs(v)); + } + } + const norm = peak > 0 ? 0.89 / peak : 1; + for (const c of ch) for (let i = 0; i < c.length; i += 1) (c as Float32Array)[i] = (c[i] as number) * norm; +} + +function wav(ch: Channels): Buffer { + const n = ch[0].length; + const data = Buffer.alloc(44 + n * 4); + data.write('RIFF', 0); + data.writeUInt32LE(36 + n * 4, 4); + data.write('WAVEfmt ', 8); + data.writeUInt32LE(16, 16); + data.writeUInt16LE(1, 20); + data.writeUInt16LE(2, 22); + data.writeUInt32LE(SR, 24); + data.writeUInt32LE(SR * 4, 28); + data.writeUInt16LE(4, 32); + data.writeUInt16LE(16, 34); + data.write('data', 36); + data.writeUInt32LE(n * 4, 40); + for (let i = 0; i < n; i += 1) { + data.writeInt16LE(Math.round((ch[0][i] as number) * 32767), 44 + i * 4); + data.writeInt16LE(Math.round((ch[1][i] as number) * 32767), 46 + i * 4); + } + return data; +} + +export function renderScore(spec: ScoreSpec): void { + const length = Math.floor(spec.seconds * SR); + const ch: Channels = [new Float32Array(length), new Float32Array(length)]; + bed(ch, spec); + pads(ch, spec.chords, spec.seconds); + heartbeat(ch, spec); + for (const c of spec.cues) cue(ch, c); + room(ch); + master(ch, spec); + mkdirSync(OUT, { recursive: true }); + writeFileSync(`${OUT}${spec.name}.wav`, wav(ch)); + console.log(`wrote ${OUT}${spec.name}.wav`); +} + +if (import.meta.main) { + renderScore(SCORE_30); + renderScore(SCORE_90); +} diff --git a/trailer/bun.lock b/trailer/bun.lock new file mode 100644 index 0000000..636177e --- /dev/null +++ b/trailer/bun.lock @@ -0,0 +1,726 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "deepmark-trailer", + "dependencies": { + "@remotion/cli": "^4.0.534", + "@remotion/google-fonts": "^4.0.534", + "@remotion/media": "^4.0.534", + "@remotion/transitions": "^4.0.534", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "remotion": "^4.0.534", + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "puppeteer-core": "^25.13.0", + "typescript": "^7.0.2", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="], + + "@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], + + "@babel/types": ["@babel/types@7.24.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w=="], + + "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.56.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-a+OY/BctP7zC4G/prHikooOVaoWXMD4+bUyEwHg2dtQiQdIwC6TDJdVYJu5dfrdSNOiAFkv1HKcYEeROdwMezg=="], + + "@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.56.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-zbmorMVlF1izwtlz5mWnu7A1rJLM+V26I0pEFr9Td1EDTwE5uAGRSW+/UZlnjwj1t00Woe3Zonfh9kb7lRRBqg=="], + + "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.56.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-p66ovEq6mZt3ejje45NOju84hIJI4v8rHfsmc1D2J34ziV0USE8AdJBzmiwqxl6nZkIAQLNB19HzbVRcQfc51g=="], + + "@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="], + + "@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="], + + "@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="], + + "@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="], + + "@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="], + + "@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.4", "", { "dependencies": { "modern-tar": "^0.8.5", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-okJ6yQMc0SBexOKxMi966omwAV1uAPwgF/YMZdb04gZQPXVjWHl9bmXjZYtIcsN1+PN3Bz4iFfGK3g+c6lZJ0Q=="], + + "@remotion/bundler": ["@remotion/bundler@4.0.534", "", { "dependencies": { "@remotion/media-parser": "4.0.534", "@remotion/studio": "4.0.534", "@remotion/studio-shared": "4.0.534", "@remotion/timeline-utils": "4.0.534", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.534", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-8PdxU94g8Y0RErUYzQFzEbPBwBU6x1QwVRCL+vqr/sW+LrEZvWrqpMxp6QPkRUpIGA+8yWhYXl8R3D72xY8qEg=="], + + "@remotion/captions": ["@remotion/captions@4.0.534", "", {}, "sha512-Auxsj2ZuvYKiljZ7g/FwWArZbwR7XvkIseDmT4UE0Vfj+9c57//hjNGp4JFn2uKIv7BeHN8zxOi0W0xUzVd2lA=="], + + "@remotion/cli": ["@remotion/cli@4.0.534", "", { "dependencies": { "@babel/parser": "7.24.1", "@remotion/bundler": "4.0.534", "@remotion/codemods": "4.0.534", "@remotion/media-utils": "4.0.534", "@remotion/player": "4.0.534", "@remotion/renderer": "4.0.534", "@remotion/studio": "4.0.534", "@remotion/studio-server": "4.0.534", "@remotion/studio-shared": "4.0.534", "dotenv": "17.3.1", "mediabunny": "1.56.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.534", "semver": "7.5.3" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-Eqqbg98XqisuetU4OMezj11G6Gesn9hRZrGjQoSpJNVIHfgzbR4deqVj5yAbddaDmmlsHBEFVfdOjayE2q+Yww=="], + + "@remotion/codemods": ["@remotion/codemods@4.0.534", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/studio-protocol": "4.0.534", "@remotion/studio-shared": "4.0.534", "ast-types": "0.16.1", "recast": "0.23.21", "remotion": "4.0.534" } }, "sha512-TDu9NTIQZafUF+qAQcLlIuud4fvghiiQGtvcFJ5yk3s8elBPamEiP8nwhbApsSL5c8Pz6ztGIJcfKcPR9Q5gWg=="], + + "@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.534", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/FW5u8KaA/VIks8kPpOByIuJGaxH6myHG3Xyx5qAfxhiBjbimp2WEYmF09GivBdHp3CT/f5RrRLT+8Jrpi1ow=="], + + "@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.534", "", { "os": "darwin", "cpu": "x64" }, "sha512-yz5FtNA2k7Q1i8DBkHAMoBFkH+mCfwdYTxIQYROyZ640vDlZoXTqcDjKq0NLO1y/dGDpjpVWbYHjH1u4D+Dqeg=="], + + "@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.534", "", { "os": "linux", "cpu": "arm64" }, "sha512-7yeVbpaSdgymw1Fs4xm08ZOI55oL2Uq0WyJrqYIhQIbg6d7Ecd6ePNbPyaeQTIa9Dq5Rxay+qbF/DV3pqOb68w=="], + + "@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.534", "", { "os": "linux", "cpu": "arm64" }, "sha512-rJd/ZPEvQaLgh6ULy9svPwQ9QRNW2jX03PkX8heoIC1JUh196XupQ9QMRTMOFNz/Fu7yWgyVbXxQVkRtJb1EvA=="], + + "@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.534", "", { "os": "linux", "cpu": "x64" }, "sha512-o7vW9vld+PmbjazKJl66sYdJZxCxW/adQd3zozV355Vb5NVqZmV8BHvSuZ1Xv210r0Vj0qtEv50ig2x0nAPmLA=="], + + "@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.534", "", { "os": "linux", "cpu": "x64" }, "sha512-6uMmkP143LF/AwjdU997xdwg7o7NCdb2eEtwt+/1qYK8/HTkd6KupqLFLBevuIQVyJ06iCwmUhZSxNL9tbWHoQ=="], + + "@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.534", "", { "os": "win32", "cpu": "x64" }, "sha512-+hp7c5ZAnomRe4U+s9tG36kmjMo0V43xV7WG353QCCHLRjKiR5KdkrU1TJQOAq53oyLdsD7BHUkEEWIBSSzSLg=="], + + "@remotion/elevenlabs": ["@remotion/elevenlabs@4.0.534", "", { "dependencies": { "@remotion/captions": "4.0.534", "zod": "4.5.4" } }, "sha512-TPd1XFSUfausKxeIljPqWOslJpeYARAeZFT2TSmvrmQBpz3i8yoG96gk3qXkqj7ebzQAqzv75bzd8jegwRU1Gw=="], + + "@remotion/google-fonts": ["@remotion/google-fonts@4.0.534", "", { "dependencies": { "remotion": "4.0.534" } }, "sha512-bgXF3QJ5Mkjmnaz1ROm1UfeP0jCJpkv4y6EPoisIVtexqVgwk9Zmp1dMemE81ok38zDLHTqM0/+5msBKWuhg3A=="], + + "@remotion/licensing": ["@remotion/licensing@4.0.534", "", {}, "sha512-pNP8OfY+vbDbjNBLYGoxGyE0SsReRy85G/QTF17mQC86dt2+pzsYlOkG2mq6R4tkt27xaQv8O5EFiJfYYfVkkw=="], + + "@remotion/media": ["@remotion/media@4.0.534", "", { "dependencies": { "mediabunny": "1.56.1", "remotion": "4.0.534", "zod": "4.5.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xdipJG5ApdwIeu5YS1+iYzUxKybFwsKIjYKPtM5ywBUZiAiaBwK8nMiGRU73xchiLygBlxCpnPQ33a7L3+btMg=="], + + "@remotion/media-parser": ["@remotion/media-parser@4.0.534", "", {}, "sha512-FE/Ww9wBOrxiMCqGwHWbPXZBxs27WzDCDhOxkYyxnOSzSePzYseDrDdwuqCGApr1cDxiwkAm91EcqvP5acPziQ=="], + + "@remotion/media-utils": ["@remotion/media-utils@4.0.534", "", { "dependencies": { "mediabunny": "1.56.1", "remotion": "4.0.534" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-ANj7kfYvO/IL4kGxfG4Cv14J45jgpoKHQ0zrYEZoGdiaiLeM/Hj+L3WyNqTySyVS3n6RzU+aqBMDiijOPpOMtQ=="], + + "@remotion/openai-whisper": ["@remotion/openai-whisper@4.0.534", "", { "dependencies": { "@remotion/captions": "4.0.534", "zod": "4.5.4" } }, "sha512-YKUkbL7PXTtlIwiBEDD1k/3cqMJXTKh9T8jRvIr/q6Bjbv26Ed0FfAd6ZY3kKX/3x4ozlHF7OTOG/3HMBgKZeA=="], + + "@remotion/paths": ["@remotion/paths@4.0.534", "", { "dependencies": { "remotion": "4.0.534" } }, "sha512-bc/kgZrpadDK95zc/qZ0x7RFN5R2p6/NceIKhLY8jhCLx6SL3BxAprBaNAFmOPX4JQ42uZp1qMAdzw7JI0GCPQ=="], + + "@remotion/player": ["@remotion/player@4.0.534", "", { "dependencies": { "remotion": "4.0.534" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-uXkg9qsuYP85eykXTayr8jjSzFJYwtPzYprMfjxnAx9u5C/H726wTE800wGUcDPCDn1SPf6TAg71pmo882Z+jA=="], + + "@remotion/renderer": ["@remotion/renderer@4.0.534", "", { "dependencies": { "@remotion/licensing": "4.0.534", "@remotion/streaming": "4.0.534", "execa": "5.1.1", "remotion": "4.0.534", "source-map": "0.8.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.534", "@remotion/compositor-darwin-x64": "4.0.534", "@remotion/compositor-linux-arm64-gnu": "4.0.534", "@remotion/compositor-linux-arm64-musl": "4.0.534", "@remotion/compositor-linux-x64-gnu": "4.0.534", "@remotion/compositor-linux-x64-musl": "4.0.534", "@remotion/compositor-win32-x64-msvc": "4.0.534" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-CLZiNRibavsEpDWSm1WsD//xLGCUA/OwhFektjiAbRNkX75Ee3bOV4cjpyihaEd0HaiUv3bUT0QLnvdwynor/w=="], + + "@remotion/sdk": ["@remotion/sdk@4.0.534", "", { "dependencies": { "@remotion/player": "4.0.534", "@remotion/studio-shared": "4.0.534", "remotion": "4.0.534" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-XMh394qeaEsbo6Q2HCaxx4uYBauNutMiQiFwBvMnIzjRr7j+SRKSv8x4d7RWlAWDTdlEeithNgwSJxm0lXgwVQ=="], + + "@remotion/shapes": ["@remotion/shapes@4.0.534", "", { "dependencies": { "@remotion/paths": "4.0.534", "remotion": "4.0.534" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-RejGlDRB+oy2+hKylGVdHSFbX2LwwbbEjc27QXbzA7dWj3D3jNsWLROR4k5GCqo9jfjuHtyW0Ue3ZE6SD0eFBA=="], + + "@remotion/streaming": ["@remotion/streaming@4.0.534", "", {}, "sha512-GE7wbEbQH15Ul4wCJYv81bjpbVIBKTxkcfCJgtYV8kiydG8v0OduYRyeUk1NTAAm80MK1oGjPOqabwRMxFGOfg=="], + + "@remotion/studio": ["@remotion/studio@4.0.534", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/captions": "4.0.534", "@remotion/codemods": "4.0.534", "@remotion/elevenlabs": "4.0.534", "@remotion/media": "4.0.534", "@remotion/media-utils": "4.0.534", "@remotion/openai-whisper": "4.0.534", "@remotion/paths": "4.0.534", "@remotion/player": "4.0.534", "@remotion/renderer": "4.0.534", "@remotion/sdk": "4.0.534", "@remotion/studio-protocol": "4.0.534", "@remotion/studio-shared": "4.0.534", "@remotion/timeline-utils": "4.0.534", "@remotion/web-renderer": "4.0.534", "@remotion/zod-types": "4.0.534", "@tanstack/react-virtual": "3.14.9", "mediabunny": "1.56.1", "memfs": "3.4.3", "open": "8.4.2", "prismjs": "1.30.0", "remotion": "4.0.534", "semver": "7.5.3", "zod": "4.5.4" }, "peerDependencies": { "@remotion/video-matting": "4.0.534", "@remotion/whisper-webgpu": "4.0.534", "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "optionalPeers": ["@remotion/video-matting", "@remotion/whisper-webgpu"] }, "sha512-sWteZLvzTMCAvpqVcXvZ7PF9gbadK7KL8RsTFf+diyHs4g1lB2Enb4vuyd7qkdFOBctE0BG4K/Srcd0Mej/axw=="], + + "@remotion/studio-protocol": ["@remotion/studio-protocol@4.0.534", "", { "dependencies": { "zod": "4.5.4" } }, "sha512-bNUlqQS3iU64FXJKOfb+irlC3U6H3pAIDEWUvyqza05KYtSF26rrR93ogtOMvBbP0aDtXFwLAOjtsH0xj4KvqQ=="], + + "@remotion/studio-server": ["@remotion/studio-server@4.0.534", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.534", "@remotion/codemods": "4.0.534", "@remotion/renderer": "4.0.534", "@remotion/studio-protocol": "4.0.534", "@remotion/studio-shared": "4.0.534", "@svgr/core": "8.1.0", "@svgr/plugin-jsx": "8.1.0", "kiwi-schema": "0.5.0", "memfs": "3.4.3", "open": "8.4.2", "recast": "0.23.21", "remotion": "4.0.534", "semver": "7.5.3", "zod": "4.5.4" } }, "sha512-lEA2XaEH99HJYXrpbyO2n2SI0o1iw2Daua9eEYwBwTDBGGc5bngmaS2lkAfTO4rcW3c2aqxqNkBQEKzIXPcIeQ=="], + + "@remotion/studio-shared": ["@remotion/studio-shared@4.0.534", "", { "dependencies": { "remotion": "4.0.534" } }, "sha512-0lLHhyN7xJZGB/MnCIDHaSVp4/Oarf/M+xflr6z3Lh1UGBU9TD5V6IWAwn/hcMuXxQWs/3VSRvwZXPtsBPCpTQ=="], + + "@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.534", "", { "dependencies": { "mediabunny": "1.56.1", "remotion": "4.0.534" } }, "sha512-4qx/e4LV/FurqLBZKbOte18zi9OzRDQE4aa+uuUvxAwnil7mN8Usg+suamPYogwq6pZ1+ZjuMbB7pQqqAU1KLQ=="], + + "@remotion/transitions": ["@remotion/transitions@4.0.534", "", { "dependencies": { "@remotion/paths": "4.0.534", "@remotion/shapes": "4.0.534", "remotion": "4.0.534" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JGy0sFYJ3aubtOyy+dXwoOGlW1NeP0sN3BslwcDF4pa+yIvzhl57wD2F9bj86pEgEF03ng9gjwiIH/cBjxqL7A=="], + + "@remotion/web-renderer": ["@remotion/web-renderer@4.0.534", "", { "dependencies": { "@mediabunny/aac-encoder": "1.56.1", "@mediabunny/flac-encoder": "1.56.1", "@mediabunny/mp3-encoder": "1.56.1", "@remotion/licensing": "4.0.534", "mediabunny": "1.56.1", "remotion": "4.0.534" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-5JlbHvU4eAA/ymvHLmHHvR+YfkTMnVcInwaNVu2kLyXGEO6HcRmqDwp2hhAGtj212/LvZKKLPLBXbfhHygelIQ=="], + + "@remotion/zod-types": ["@remotion/zod-types@4.0.534", "", { "dependencies": { "remotion": "4.0.534" } }, "sha512-WYIHdVFGrOBIX7v7v5GIJ1LCjv8oyruJD1sl06ZpDBfMzJspSTQrXH2hoT37YZcA+RUpyUomHkQrdiqxk/Jxgg=="], + + "@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="], + + "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="], + + "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="], + + "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="], + + "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="], + + "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="], + + "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="], + + "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="], + + "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="], + + "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="], + + "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="], + + "@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="], + + "@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="], + + "@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="], + + "@svgr/babel-plugin-add-jsx-attribute": ["@svgr/babel-plugin-add-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g=="], + + "@svgr/babel-plugin-remove-jsx-attribute": ["@svgr/babel-plugin-remove-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA=="], + + "@svgr/babel-plugin-remove-jsx-empty-expression": ["@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA=="], + + "@svgr/babel-plugin-replace-jsx-attribute-value": ["@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ=="], + + "@svgr/babel-plugin-svg-dynamic-title": ["@svgr/babel-plugin-svg-dynamic-title@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og=="], + + "@svgr/babel-plugin-svg-em-dimensions": ["@svgr/babel-plugin-svg-em-dimensions@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g=="], + + "@svgr/babel-plugin-transform-react-native-svg": ["@svgr/babel-plugin-transform-react-native-svg@8.1.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q=="], + + "@svgr/babel-plugin-transform-svg-component": ["@svgr/babel-plugin-transform-svg-component@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw=="], + + "@svgr/babel-preset": ["@svgr/babel-preset@8.1.0", "", { "dependencies": { "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", "@svgr/babel-plugin-transform-svg-component": "8.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug=="], + + "@svgr/core": ["@svgr/core@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", "camelcase": "^6.2.0", "cosmiconfig": "^8.1.3", "snake-case": "^3.0.4" } }, "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA=="], + + "@svgr/hast-util-to-babel-ast": ["@svgr/hast-util-to-babel-ast@8.0.0", "", { "dependencies": { "@babel/types": "^7.21.3", "entities": "^4.4.0" } }, "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q=="], + + "@svgr/plugin-jsx": ["@svgr/plugin-jsx@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", "@svgr/hast-util-to-babel-ast": "8.0.0", "svg-parser": "^2.0.4" }, "peerDependencies": { "@svgr/core": "*" } }, "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA=="], + + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.9", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.12", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + + "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], + + "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="], + + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], + + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + + "chromium-bidi": ["chromium-bidi@157.0.8090-0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^4.6.5" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "devtools-protocol": ["devtools-protocol@0.0.1696802", "", {}, "sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg=="], + + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.452", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + + "fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="], + + "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "mediabunny": ["mediabunny@1.56.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-DNOm0bRBmeOxJgCK/g3LxNWbxY5morTauNdlychujGmJ9L37ezVezkNoI1WtO2Xdb4WlWWJlYfIKnOEzd5phRw=="], + + "memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.8.5", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + + "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + + "node-releases": ["node-releases@2.0.58", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="], + + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], + + "postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="], + + "postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="], + + "postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="], + + "postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "puppeteer-core": ["puppeteer-core@25.13.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.4", "chromium-bidi": "157.0.8090-0", "devtools-protocol": "0.0.1696802", "typed-query-selector": "^2.12.3", "webdriver-bidi-protocol": "1.0.1", "ws": "^8.21.3" } }, "sha512-cyBS0OEx3e9NS53B5F6+UazXtR7f9Tw0cR1/FbPW59/hV6JPZbkIiMnxK2543IkFp3rsGlLn1iPDLF77TJsJsA=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="], + + "remotion": ["remotion@4.0.534", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-gSfMreFJgncWEHGsIWksicmEGsTrGMIAgZ87WS4e+bIB+nVsAN67UXWbQ88xGwUONm3xzwu9Ty786kFDcgZoaQ=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "schema-utils": ["schema-utils@4.5.0", "", { "dependencies": { "@types/json-schema": "^7.0.15", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0" } }, "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA=="], + + "semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="], + + "source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], + + "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="], + + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "svg-parser": ["svg-parser@2.1.0", "", {}, "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "terser": ["terser@5.51.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "@minify-html/node": "*", "@swc/core": "*", "@swc/css": "*", "@swc/html": "*", "clean-css": "*", "cssnano": "*", "csso": "*", "esbuild": "*", "html-minifier-terser": "*", "lightningcss": "*", "postcss": "*", "uglify-js": "*", "webpack": "^5.1.0" }, "optionalPeers": ["@minify-html/node", "@swc/core", "@swc/css", "@swc/html", "clean-css", "cssnano", "csso", "esbuild", "html-minifier-terser", "lightningcss", "postcss", "uglify-js"] }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typed-query-selector": ["typed-query-selector@2.12.3", "", {}, "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@1.0.1", "", {}, "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw=="], + + "webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="], + + "webpack-sources": ["webpack-sources@3.6.0", "", {}, "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@babel/core/@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/core/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/generator/@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/generator/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/helpers/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/template/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/traverse/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@remotion/renderer/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "chromium-bidi/zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "css-loader/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/trailer/capture/plan.ts b/trailer/capture/plan.ts new file mode 100644 index 0000000..7680da8 --- /dev/null +++ b/trailer/capture/plan.ts @@ -0,0 +1,111 @@ +/** + * Finds a run worth filming. The footage must be real gameplay, so instead of + * animating a fake one this searches seeds with the game's own simulation for a + * winning extraction with a dramatic arc, and prints the action log to replay. + * + * bun run capture/plan.ts [--base=1] [--weapon=1] [--charm=0] [--rank=0] + */ +import { isWalkable, type Point } from '../../client/src/game/floor'; +import { ActionCode, applyAction, drainEvents, newRun, type RunState } from '../../client/src/game/state'; +import type { Loadout } from '../../client/src/game/loadout'; + +const MOVES: { code: ActionCode; dx: number; dy: number }[] = [ + { code: ActionCode.Up, dx: 0, dy: -1 }, + { code: ActionCode.Down, dx: 0, dy: 1 }, + { code: ActionCode.Left, dx: -1, dy: 0 }, + { code: ActionCode.Right, dx: 1, dy: 0 }, +]; +const SEED_COUNT = 40_000; +const STEP_CAP = 600; + +export interface Plan { + seed: number; + score: number; + skipped: number[]; + loadout: Loadout; + actions: number[]; + hpLeft: number; + maxHp: number; + relics: number; + fights: number; +} + +function stepToward(state: RunState, goal: Point): ActionCode | null { + const key = (p: Point): string => `${p.x},${p.y}`; + const start: Point = { x: state.player.x, y: state.player.y }; + const first = new Map(); + const seen = new Set([key(start)]); + const queue: Point[] = [start]; + while (queue.length > 0) { + const at = queue.shift() as Point; + if (at.x === goal.x && at.y === goal.y) return first.get(key(at)) ?? null; + for (const m of MOVES) { + const next = { x: at.x + m.dx, y: at.y + m.dy }; + const k = key(next); + if (seen.has(k) || !isWalkable(state.layout, next.x, next.y)) continue; + seen.add(k); + first.set(k, first.get(key(at)) ?? m.code); + queue.push(next); + } + } + return null; +} + +/** Plays greedily: every relic, every guard, down to floor 3, then extract. */ +/** `skipFloors` lists floors whose relic the delver leaves behind, which is how a run ends with fewer relics. */ +export function play(loadout: Loadout, seed: number, skipFloors: number[] = []): Plan | null { + const state = newRun(loadout, seed); + let fights = 0; + for (let step = 0; step < STEP_CAP && state.mode !== 'result'; step += 1) { + if (state.mode === 'combat') { + applyAction(state, ActionCode.Attack); + fights += 1; + } else if (state.mode === 'choice') { + applyAction(state, state.floorNumber < 3 ? ActionCode.Descend : ActionCode.Extract); + } else { + const relic = skipFloors.includes(state.floorNumber) ? undefined : state.relics.find((r) => !r.collected); + const goal: Point = relic ?? state.layout.stairsPos; + const code = stepToward(state, goal); + if (code === null) return null; + applyAction(state, code); + } + drainEvents(state); + } + if (state.mode !== 'result' || state.resultKind !== 'extracted' || state.floorNumber < 3) return null; + return { + seed, + score: state.scoreThisRun, + skipped: skipFloors, + loadout, + actions: state.actions, + hpLeft: state.player.hp, + maxHp: state.player.maxHp, + relics: state.relicsThisRun, + fights, + }; +} + +function arg(name: string, fallback: number): number { + const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); + return hit ? Number(hit.split('=')[1]) : fallback; +} + +/** What the sealed result card in the game's preview shows, so the film agrees with itself. */ +const CARD = { relics: 2, score: 4, hpLeft: 1 } as const; + +if (import.meta.main) { + const loadout: Loadout = { base: arg('base', 1), weapon: arg('weapon', 1), charm: arg('charm', 2), rank: arg('rank', 0) }; + const found: Plan[] = []; + const masks: number[][] = [[], [1], [2], [3], [1, 2], [1, 3], [2, 3]]; + for (let seed = 1; seed <= SEED_COUNT; seed += 1) { + for (const mask of masks) { + const plan = play(loadout, seed, mask); + if (plan && plan.relics === CARD.relics && plan.score === CARD.score && plan.hpLeft === CARD.hpLeft) found.push(plan); + } + } + found.sort((a, b) => a.actions.length - b.actions.length); + console.log(`${found.length} runs match the result card, loadout ${JSON.stringify(loadout)}`); + for (const p of found.slice(0, 8)) { + console.log(`seed ${p.seed} skip [${p.skipped}] relics ${p.relics} score ${p.score} hp ${p.hpLeft}/${p.maxHp} fights ${p.fights} actions ${p.actions.length}`); + } +} diff --git a/trailer/capture/record.ts b/trailer/capture/record.ts new file mode 100644 index 0000000..6558d97 --- /dev/null +++ b/trailer/capture/record.ts @@ -0,0 +1,277 @@ +/** + * Records the real game, from the production build, into trailer/public/footage. + * Stellar hosts are blocked, so the run is a practice run whose seed comes from + * the challenge link: the footage is deterministic and the same on every take. + * + * bun run build && bun run preview --port 4173 (in client/) + * bun run capture/record.ts [run|title|...] (in trailer/) + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import puppeteer, { type KeyInput, type Page } from 'puppeteer-core'; +import { ActionCode, applyAction, drainEvents, newRun, type RunState } from '../../client/src/game/state'; +import { play } from './plan'; + +const PREVIEW_PORT = 4173; +const BASE_URL = process.env.GAME_URL ?? `http://127.0.0.1:${PREVIEW_PORT}/`; +const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const OUT = new URL('../public/footage/', import.meta.url).pathname; +const CUES_OUT = new URL('../src/generated/', import.meta.url).pathname; +/** Filmed at twice the layout size so the edit can zoom into the interface without softening it. */ +const VIEWPORT = { width: 1920, height: 1080, deviceScaleFactor: 2 } as const; +/** + * The filmed run. It was chosen because it ends on exactly what the game's sealed + * result card shows in preview (floor 3, 2 relics, score 4, 1 HP left), so the + * played footage and the card are one story. See `bun run capture/plan.ts`. + */ +const FILM = { seed: 67, base: 1, weapon: 1, charm: 2, rank: 0, skipFloors: [2], name: 'Brannoch' } as const; + +/** Pause after each input so the camera can read the move, the strike and the pickup. */ +const PAUSE_MS = { move: 330, attack: 720, choice: 1500, descend: 3600 } as const; +const KEY_FOR_ACTION: Record = { + 0: 'ArrowUp', + 1: 'ArrowDown', + 2: 'ArrowLeft', + 3: 'ArrowRight', + 4: 'Enter', + 5: 'Escape', + 6: 'Enter', + 7: 'Escape', +}; + +export interface Cue { + /** Epoch seconds while recording, rebased to seconds from the first frame when written. */ + t: number; + index: number; + action: number; + floor: number; + mode: string; + hp: number; + relics: number; + events: string[]; + /** Tile of the delver and of the guard being fought, if any, so the edit can frame them. */ + player: { x: number; y: number }; + enemy: { x: number; y: number } | null; +} +const cues: Cue[] = []; +/** Named instants inside a take, in epoch seconds until written. */ +const marks: Record = {}; +const mark = (name: string): void => { + marks[name] = Date.now() / 1000; +}; + +/** Hosts the game talks to. Blocking them forces the offline practice run, so every take is identical. */ +const STELLAR_HOSTS: readonly string[] = ['soroban-testnet.stellar.org', 'horizon-testnet.stellar.org', 'friendbot.stellar.org']; + +async function blockStellar(page: Page): Promise { + await page.setRequestInterception(true); + page.on('request', (req) => { + if (STELLAR_HOSTS.includes(new URL(req.url()).host)) void req.abort(); + else void req.continue(); + }); +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +async function open(path: string): Promise<{ page: Page; close: () => Promise }> { + const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: true, + args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required', '--hide-scrollbars'], + defaultViewport: VIEWPORT, + }); + const page = await browser.newPage(); + await blockStellar(page); + await page.goto(`${BASE_URL}${path}`, { waitUntil: 'load' }); + return { page, close: () => browser.close() }; +} + +interface Frame { + file: string; + at: number; +} + +/** + * Chrome's own screencast, one JPEG per repaint with its timestamp, then ffmpeg + * lays them on a constant 30 fps timeline. Puppeteer's recorder was avoided: it + * never returned from `stop()` against this ffmpeg. + */ +async function record(name: string, path: string, scene: (page: Page) => Promise): Promise { + const { page, close } = await open(path); + const dir = `${OUT}.frames-${name}/`; + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const frames: Frame[] = []; + const cdp = await page.createCDPSession(); + cdp.on('Page.screencastFrame', (event: { data: string; sessionId: number; metadata: { timestamp?: number } }) => { + const file = `${dir}${String(frames.length).padStart(6, '0')}.jpg`; + writeFileSync(file, Buffer.from(event.data, 'base64')); + frames.push({ file, at: event.metadata.timestamp ?? Date.now() / 1000 }); + void cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }); + }); + await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 100, everyNthFrame: 1, maxWidth: 3840, maxHeight: 2160 }); + try { + await scene(page); + } finally { + await cdp.send('Page.stopScreencast'); + await close(); + } + encode(name, dir, frames); + const origin0 = frames[0]?.at ?? 0; + if (Object.keys(marks).length > 0) { + const rebased = Object.fromEntries(Object.entries(marks).map(([k, v]) => [k, Math.round((v - origin0) * 1000) / 1000])); + mkdirSync(CUES_OUT, { recursive: true }); + writeFileSync(`${CUES_OUT}${name}-marks.json`, JSON.stringify(rebased, null, 1)); + for (const k of Object.keys(marks)) delete marks[k]; + } + if (cues.length > 0) { + const origin = frames[0]?.at ?? 0; + for (const c of cues) c.t = Math.round((c.t - origin) * 1000) / 1000; + mkdirSync(CUES_OUT, { recursive: true }); + writeFileSync(`${CUES_OUT}${name}-cues.json`, JSON.stringify(cues, null, 1)); + cues.length = 0; + } +} + +function encode(name: string, dir: string, frames: Frame[]): void { + if (frames.length < 2) throw new Error(`no frames captured for ${name}`); + const lines: string[] = []; + frames.forEach((frame, i) => { + const next = frames[i + 1]; + const seconds = next ? Math.max(next.at - frame.at, 0.001) : 0.5; + lines.push(`file '${frame.file}'`, `duration ${seconds.toFixed(4)}`); + }); + lines.push(`file '${frames[frames.length - 1]?.file}'`); + const list = `${dir}list.txt`; + writeFileSync(list, lines.join('\n')); + const file = `${OUT}${name}.mp4`; + const result = spawnSync( + 'ffmpeg', + ['-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-vf', 'fps=30,scale=3840:2160:flags=lanczos,format=yuv420p', '-c:v', 'libx264', '-crf', '16', '-preset', 'medium', file], + { stdio: 'inherit' }, + ); + if (result.status !== 0) throw new Error(`ffmpeg failed for ${name}`); + rmSync(dir, { recursive: true, force: true }); + console.log(`recorded ${file} (${frames.length} frames)`); +} + +async function forgeAndDescend(page: Page): Promise { + await page.waitForSelector('.title-screen'); + await sleep(1800); + await page.click('[data-action="confirm"]'); + await page.waitForSelector('form.creator'); + await sleep(900); + await page.click('form.creator input.input', { count: 3 }); + await page.keyboard.type(FILM.name, { delay: 85 }); + await sleep(900); + await page.evaluate(() => (document.querySelector('form.creator') as HTMLFormElement).requestSubmit()); + await page.waitForFunction( + () => document.getElementById('hud') && !(document.getElementById('hud') as HTMLElement).hidden, + { timeout: 90_000 }, + ); + await sleep(2600); +} + +function activeEnemy(state: RunState): { x: number; y: number } | null { + const enemy = state.activeEnemyIndex === null ? undefined : state.enemies[state.activeEnemyIndex]; + return enemy ? { x: enemy.x, y: enemy.y } : null; +} + +async function playRun(page: Page): Promise { + const loadout = { base: FILM.base, weapon: FILM.weapon, charm: FILM.charm, rank: FILM.rank }; + const plan = play(loadout, FILM.seed, [...FILM.skipFloors]); + if (!plan) throw new Error('the filmed seed no longer produces a winning run'); + console.log(`replaying ${plan.actions.length} actions, expecting hp ${plan.hpLeft}/${plan.maxHp}`); + // The explainer graphics draw this very log, so what they show is the run on screen, not an illustration. + mkdirSync(CUES_OUT, { recursive: true }); + writeFileSync( + `${CUES_OUT}run-plan.json`, + JSON.stringify({ seed: plan.seed, actions: plan.actions, floor: 3, relics: plan.relics, score: plan.score, hpLeft: plan.hpLeft, maxHp: plan.maxHp, name: FILM.name }, null, 1), + ); + // A shadow simulation labels each input with what it did, so the edit can cut on real events. + const shadow = newRun(loadout, FILM.seed); + for (const [index, action] of plan.actions.entries()) { + applyAction(shadow, action as ActionCode); + cues.push({ + t: Date.now() / 1000, + index, + action, + floor: shadow.floorNumber, + mode: shadow.mode, + hp: shadow.player.hp, + relics: shadow.relicsThisRun, + events: drainEvents(shadow).map((e) => e.type), + player: { x: shadow.player.x, y: shadow.player.y }, + enemy: activeEnemy(shadow), + }); + await page.keyboard.press(KEY_FOR_ACTION[action] as KeyInput); + if (action === 6) await sleep(PAUSE_MS.descend); + else if (action === 7) await sleep(PAUSE_MS.choice); + else await sleep(action === 4 || action === 5 ? PAUSE_MS.attack : PAUSE_MS.move); + } + await sleep(3500); +} + +/** Design previews built into the game for localhost, so every screen can be filmed without a chain. */ +function still(name: string, path: string, selector: string, seconds: number): () => Promise { + return () => + record(name, path, async (page) => { + await page.waitForSelector(selector, { timeout: 30_000 }); + await sleep(seconds * 1000); + }); +} + +const scenes: Record Promise> = { + run: () => + record('run', `?seed=${FILM.seed}&base=${FILM.base}&weapon=${FILM.weapon}&charm=${FILM.charm}`, async (page) => { + await forgeAndDescend(page); + await playRun(page); + }), + title: still('title', '', '.title-screen', 7), + descent: still('descent', '?preview=descent&dive=1', '#overlay *', 11), + sealed: () => + record('sealed', '?preview=result&hero=1', async (page) => { + await page.waitForSelector('#overlay *', { timeout: 30_000 }); + await page.waitForFunction(() => document.body.innerText.includes('Sealed on Stellar'), { timeout: 30_000 }); + mark('sealed'); + await sleep(5500); + }), + vault: still('vault', '?preview=vault', '#overlay *', 6), +}; + +/** Builds the production client and serves it, unless GAME_URL points at a build that is already up. */ +async function startPreview(): Promise<(() => void) | null> { + if (process.env.GAME_URL) return null; + const client = new URL('../../client/', import.meta.url).pathname; + const built = spawnSync('bun', ['run', 'build'], { cwd: client, stdio: 'inherit' }); + if (built.status !== 0) throw new Error('the client build failed'); + // Detached, so the whole process group (bun and the vite it starts) can be stopped together. + const server = spawn('bun', ['run', 'preview', '--port', String(PREVIEW_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: client, + stdio: 'ignore', + detached: true, + }); + const stop = (): void => { + if (server.pid) process.kill(-server.pid); + }; + for (let i = 0; i < 60; i += 1) { + try { + if ((await fetch(BASE_URL)).ok) return stop; + } catch { + // Not listening yet. + } + await sleep(500); + } + stop(); + throw new Error('the preview server did not start'); +} + +const wanted = process.argv.slice(2); +const stopPreview = await startPreview(); +try { + for (const [name, fn] of Object.entries(scenes)) { + if (wanted.length === 0 || wanted.includes(name)) await fn(); + } +} finally { + stopPreview?.(); +} diff --git a/trailer/package.json b/trailer/package.json new file mode 100644 index 0000000..e04127e --- /dev/null +++ b/trailer/package.json @@ -0,0 +1,35 @@ +{ + "name": "deepmark-trailer", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "bun@1.3.14", + "scripts": { + "studio": "remotion studio", + "typecheck": "tsc --noEmit", + "capture": "bun run capture/record.ts", + "sync": "bun run scripts/sync-assets.ts", + "audio": "bun run audio/synth.ts", + "render:30": "remotion render src/index.ts Trailer30 out/deepmark-trailer-30s.mp4 --crf=22 --x264-preset=slow --audio-codec=aac --audio-bitrate=160k --log=warn", + "render:90": "remotion render src/index.ts Trailer90 out/deepmark-trailer-90s.mp4 --crf=20 --x264-preset=slow --audio-codec=aac --audio-bitrate=192k --log=warn", + "media": "bun run scripts/readme-media.ts", + "render:card": "remotion render src/index.ts ShareCard out/deepmark-share-card.mp4 --crf=22 --x264-preset=slow --log=warn", + "build:all": "bun run sync && bun run audio && bun run capture && bun run render:30 && bun run render:90 && bun run render:card && bun run media" + }, + "dependencies": { + "@remotion/cli": "^4.0.534", + "@remotion/google-fonts": "^4.0.534", + "@remotion/media": "^4.0.534", + "@remotion/transitions": "^4.0.534", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "remotion": "^4.0.534" + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "puppeteer-core": "^25.13.0", + "typescript": "^7.0.2" + } +} \ No newline at end of file diff --git a/trailer/remotion.config.ts b/trailer/remotion.config.ts new file mode 100644 index 0000000..0172462 --- /dev/null +++ b/trailer/remotion.config.ts @@ -0,0 +1,6 @@ +import { Config } from '@remotion/cli/config'; + +Config.setVideoImageFormat('png'); +Config.setPixelFormat('yuv420p'); +Config.setCodec('h264'); +Config.setOverwriteOutput(true); diff --git a/trailer/scripts/readme-media.ts b/trailer/scripts/readme-media.ts new file mode 100644 index 0000000..9ca7946 --- /dev/null +++ b/trailer/scripts/readme-media.ts @@ -0,0 +1,51 @@ +/** + * README media: screenshots of the real game and a short GIF cut from the + * trailer render. Everything is derived from the recordings and the render, so + * it can be regenerated with one command after the game changes. + * + * bun run media + */ +import { mkdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { RUN } from '../src/footage-map'; + +const FOOTAGE = new URL('../public/footage/', import.meta.url).pathname; +/** Committed with the repository, so the README can show them. */ +const OUT = new URL('../../docs/media/', import.meta.url).pathname; +const SCRATCH = new URL('../out/', import.meta.url).pathname; +const TRAILER = new URL('../out/deepmark-trailer-30s.mp4', import.meta.url).pathname; +const SHOT_WIDTH = 1920; + +function ffmpeg(args: string[]): void { + const result = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', ...args], { stdio: 'inherit' }); + if (result.status !== 0) throw new Error(`ffmpeg ${args.join(' ')} failed`); +} + +/** A still from a recording at `at` seconds, scaled to a README friendly width. */ +function still(name: string, source: string, at: number, filter = ''): void { + const scale = `scale=${SHOT_WIDTH}:-1:flags=lanczos`; + ffmpeg(['-ss', String(at), '-i', `${FOOTAGE}${source}`, '-frames:v', '1', '-vf', filter ? `${filter},${scale}` : scale, '-q:v', '2', `${OUT}${name}.jpg`]); +} + +mkdirSync(OUT, { recursive: true }); +mkdirSync(SCRATCH, { recursive: true }); + +// The board, with the header and HUD cropped away, at the moments worth showing. +const BOARD_CROP = 'crop=2688:1512:576:292'; +still('floor-1-fight', 'run.mp4', RUN.floor1Fight + 0.45, BOARD_CROP); +still('floor-2', 'run.mp4', RUN.floor2Fight + 0.2, BOARD_CROP); +still('floor-3-boss', 'run.mp4', RUN.bossHit + 0.1, BOARD_CROP); +still('title', 'title.mp4', 3.5); +still('descent', 'descent.mp4', 2.6); +still('delver-forged', 'run.mp4', 4.4); + +// A looping GIF of the fight, small enough to sit in a README. +const palette = `${SCRATCH}palette.png`; +const gifFilter = 'fps=12,scale=640:-1:flags=lanczos'; +const start = String(RUN.bossFight - 0.7); +ffmpeg(['-ss', start, '-t', '3.4', '-i', `${FOOTAGE}run.mp4`, '-vf', `crop=2688:1512:576:292,${gifFilter},palettegen=max_colors=80`, palette]); +ffmpeg(['-ss', start, '-t', '3.4', '-i', `${FOOTAGE}run.mp4`, '-i', palette, '-lavfi', `crop=2688:1512:576:292,${gifFilter}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=4`, `${OUT}boss-fight.gif`]); + +// The poster frame for a video embed: the hook, which has no text on purpose. +ffmpeg(['-ss', '4.2', '-i', TRAILER, '-frames:v', '1', '-q:v', '2', `${OUT}poster.jpg`]); +console.log(`wrote README media to ${OUT}`); diff --git a/trailer/scripts/sync-assets.ts b/trailer/scripts/sync-assets.ts new file mode 100644 index 0000000..1e5ca07 --- /dev/null +++ b/trailer/scripts/sync-assets.ts @@ -0,0 +1,44 @@ +/** + * Copies the painted art the trailer uses straight from the shipped client, so + * the trailer can never show an outdated asset, and makes the film grain tile. + */ +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +const ART_FROM = new URL('../../client/public/art/', import.meta.url).pathname; +const ART_TO = new URL('../public/art/', import.meta.url).pathname; +const FX_TO = new URL('../public/fx/', import.meta.url).pathname; + +const ART: readonly string[] = [ + 'title-key-art.webp', + 'boss-intro.webp', + 'victory-bg.webp', + 'vault-bg.webp', + 'share-card-bg.webp', + 'logo-lockup.webp', + 'cinder.webp', + 'delver-cinder-veteran.webp', + 'relic-common.webp', + 'relic-rare.webp', + 'relic-core.webp', + 'reaver-hook.webp', + 'oil-flask.webp', + 'stone-heart.webp', + 'warden-prime.webp', +]; + +mkdirSync(ART_TO, { recursive: true }); +mkdirSync(FX_TO, { recursive: true }); +for (const file of ART) { + if (!existsSync(`${ART_FROM}${file}`)) throw new Error(`missing art: ${file}`); + copyFileSync(`${ART_FROM}${file}`, `${ART_TO}${file}`); +} + +// Chrome decodes webp, but a flat PNG tile is what the grain overlay repeats. +const grain = spawnSync( + 'ffmpeg', + ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=gray:s=512x512', '-vf', 'noise=alls=90:allf=t+u,format=gray', '-frames:v', '1', `${FX_TO}grain.png`], + { stdio: 'inherit' }, +); +if (grain.status !== 0) throw new Error('could not make the grain tile'); +console.log(`synced ${ART.length} art files and the grain tile`); diff --git a/trailer/src/Root.tsx b/trailer/src/Root.tsx new file mode 100644 index 0000000..2078299 --- /dev/null +++ b/trailer/src/Root.tsx @@ -0,0 +1,14 @@ +import { Composition } from 'remotion'; +import { FPS, HEIGHT, WIDTH, sec } from './theme'; +import { T30, T90 } from './timeline'; +import { Trailer30 } from './compositions/Trailer30'; +import { Trailer90 } from './compositions/Trailer90'; +import { CARD_FRAMES, CARD_HEIGHT, CARD_WIDTH, ShareCardAnimated } from './compositions/ShareCardAnimated'; + +export const Root: React.FC = () => ( + <> + + + + +); diff --git a/trailer/src/camera.ts b/trailer/src/camera.ts new file mode 100644 index 0000000..7411c33 --- /dev/null +++ b/trailer/src/camera.ts @@ -0,0 +1,62 @@ +import { RUN_CUES, type RunCue } from './footage-map'; +import type { Rect } from './components/Footage'; + +/** Where the game's board sits in the 1920 by 1080 layout: 13 by 9 tiles of about 103 px. */ +const BOARD_X = 288; +const BOARD_Y = 77; +const TILE = 1344 / 13; +const BOARD_W = 1344; +const BOARD_H = 9 * TILE; + +/** How far back, in seconds, the camera remembers where the action was. Larger is lazier. */ +const MEMORY = 0.7; +const TAPS = 8; + +const centerOf = (tx: number, ty: number): { x: number; y: number } => ({ + x: BOARD_X + (tx + 0.5) * TILE, + y: BOARD_Y + (ty + 0.5) * TILE, +}); + +function focusAt(t: number): { x: number; y: number } { + let latest: RunCue | undefined; + for (const cue of RUN_CUES) { + if (cue.t <= t) latest = cue; + else break; + } + const cue = latest ?? RUN_CUES[0]; + if (!cue) return centerOf(6, 4); + const player = centerOf(cue.player.x, cue.player.y); + if (!cue.enemy) return player; + const enemy = centerOf(cue.enemy.x, cue.enemy.y); + return { x: (player.x * 2 + enemy.x) / 3, y: (player.y * 2 + enemy.y) / 3 }; +} + +/** + * A camera that follows the delver and leans toward the guard in a fight. It is + * a pure function of the recording's time, so any frame renders on its own: + * the target is averaged over the last moments with a fading weight, which is + * what a damped camera does, without needing the previous frame. + */ +export function followCamera(width: number): (t: number) => Rect { + const height = (width * 9) / 16; + return (t) => { + let x = 0; + let y = 0; + let weight = 0; + for (let i = 0; i < TAPS; i += 1) { + const w = Math.exp(-(i / (TAPS - 1)) * 2.2); + const f = focusAt(t - (i / (TAPS - 1)) * MEMORY); + x += f.x * w; + y += f.y * w; + weight += w; + } + const cx = x / weight; + const cy = y / weight; + return { + x: Math.min(Math.max(cx - width / 2, BOARD_X), BOARD_X + BOARD_W - width), + y: Math.min(Math.max(cy - height / 2, BOARD_Y), BOARD_Y + BOARD_H - height), + w: width, + h: height, + }; + }; +} diff --git a/trailer/src/components/ActionStrip.tsx b/trailer/src/components/ActionStrip.tsx new file mode 100644 index 0000000..3d4dbd1 --- /dev/null +++ b/trailer/src/components/ActionStrip.tsx @@ -0,0 +1,56 @@ +import { random, useCurrentFrame, useVideoConfig } from 'remotion'; +import { MONO } from '../fonts'; +import { COLOR } from '../theme'; +import { ramp } from '../motion'; +import { ActionGlyph } from './Glyphs'; + +interface ActionStripProps { + actions: readonly number[]; + columns: number; + cell?: number; + /** Seconds between one cell appearing and the next. */ + every?: number; + /** Seconds before the first cell. */ + start?: number; + /** Index to light up in cyan, such as the byte the contract is reading. */ + cursor?: number; +} + +/** The action log, one cell per byte. Real data: the same inputs the recorded run was played with. */ +export const ActionStrip: React.FC = ({ actions, columns, cell = 46, every = 0.07, start = 0, cursor }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const t = frame / fps; + return ( +
    + {actions.map((code, i) => { + const shown = ramp(t * fps, (start + i * every) * fps, (start + i * every) * fps + 8); + const hot = cursor !== undefined && i === cursor; + const jitter = random(`cell-${i}`); + return ( +
    + + {code} +
    + ); + })} +
    + ); +}; diff --git a/trailer/src/components/Atmosphere.tsx b/trailer/src/components/Atmosphere.tsx new file mode 100644 index 0000000..18a6d69 --- /dev/null +++ b/trailer/src/components/Atmosphere.tsx @@ -0,0 +1,112 @@ +import { AbsoluteFill, Img, random, staticFile, useCurrentFrame, useVideoConfig } from 'remotion'; +import { COLOR } from '../theme'; + +/** Film grain: one noise tile, jumped to a new offset every frame. */ +export const Grain: React.FC<{ opacity?: number }> = ({ opacity = 0.1 }) => { + const frame = useCurrentFrame(); + const x = Math.floor(random(`grain-x-${frame}`) * 512); + const y = Math.floor(random(`grain-y-${frame}`) * 512); + return ( + + ); +}; + +export const Vignette: React.FC<{ strength?: number }> = ({ strength = 0.7 }) => ( + +); + +interface EmbersProps { + count?: number; + /** 0 to 1, multiplies brightness so embers can swell with the music. */ + intensity?: number; + seed?: string; +} + +/** Slow rising sparks. Every value is a pure function of the frame, so renders match. */ +export const Embers: React.FC = ({ count = 46, intensity = 1, seed = 'embers' }) => { + const frame = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + const t = frame / fps; + return ( + + {Array.from({ length: count }, (_, i) => { + const r = (k: string): number => random(`${seed}-${i}-${k}`); + const speed = 26 + r('speed') * 62; + const travel = height + 120; + const y = height + 60 - ((t * speed + r('phase') * travel) % travel); + const sway = Math.sin(t * (0.5 + r('sway') * 1.1) + r('swayphase') * 6.28) * (14 + r('amp') * 40); + const x = r('x') * width + sway; + const size = 2 + r('size') * 5; + const flicker = 0.55 + 0.45 * Math.sin(t * (2 + r('f') * 5) + r('fp') * 6.28); + const warm = r('hue') > 0.35; + const color = warm ? '255,190,96' : '126,240,224'; + const edge = Math.min(1, Math.max(0, y / 160), Math.max(0, (height - y) / 120)); + return ( +
    + ); + })} + + ); +}; + +/** A single bright frame, for hard cuts that should feel struck, not swapped. */ +export const Flash: React.FC<{ frames?: number; color?: string; peak?: number }> = ({ frames = 6, color = COLOR.cyan, peak = 0.55 }) => { + const frame = useCurrentFrame(); + if (frame >= frames) return null; + const opacity = peak * (1 - frame / frames) ** 2; + return ; +}; + +/** Painted image, full bleed, with a camera push. */ +export const PaintedPlate: React.FC<{ + src: string; + from?: number; + to?: number; + drift?: [number, number]; + brightness?: number; +}> = ({ src, from = 1, to = 1.12, drift = [0, 0], brightness = 1 }) => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const p = frame / Math.max(1, durationInFrames - 1); + const scale = from + (to - from) * p; + return ( + + + + ); +}; diff --git a/trailer/src/components/Footage.tsx b/trailer/src/components/Footage.tsx new file mode 100644 index 0000000..cde51d1 --- /dev/null +++ b/trailer/src/components/Footage.tsx @@ -0,0 +1,85 @@ +import { Video } from '@remotion/media'; +import { AbsoluteFill, interpolate, staticFile, useCurrentFrame, useVideoConfig } from 'remotion'; +import { COLOR, HEIGHT, WIDTH, sec } from '../theme'; +import { SMOOTH, ramp, sceneFade } from '../motion'; + +/** A window onto the 1920 by 1080 layout of the recorded game, in layout pixels. */ +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** The playable board of the game, with the HUD, header and prompts cropped out. */ +export const BOARD: Rect = { x: 288, y: 146, w: 1344, h: 756 }; + +const lerp = (a: number, b: number, p: number): number => a + (b - a) * p; + +interface FootageProps { + /** File under public/footage. */ + src: string; + /** Where in the recording this shot starts, in seconds. */ + from: number; + rect?: Rect; + /** A second window to travel to over the length of the shot. */ + rectTo?: Rect; + /** A camera that is a function of the recording's time. Overrides `rect`. */ + camera?: (t: number) => Rect; + /** Plays the recording faster or slower. */ + rate?: number; + grade?: boolean; +} + +/** + * One shot of real gameplay. The recording is 4K, the window crops it to 16:9 + * and an optional second window turns the crop into a slow camera move. + */ +export const Footage: React.FC = ({ src, from, rect, rectTo, camera, rate = 1, grade = true }) => { + const frame = useCurrentFrame(); + const { durationInFrames, fps } = useVideoConfig(); + const p = rectTo ? ramp(frame, 0, durationInFrames, SMOOTH) : 0; + const target = rectTo ?? rect ?? BOARD; + const base = rect ?? BOARD; + const moved = camera ? camera(from + (frame / fps) * rate) : null; + const w = moved ? moved.w : lerp(base.w, target.w, p); + const x = moved ? moved.x : lerp(base.x, target.x, p); + const y = moved ? moved.y : lerp(base.y, target.y, p); + const scale = WIDTH / w; + return ( + + + ); +}; + +interface FadeProps { + children: React.ReactNode; + inFrames?: number; + outFrames?: number; +} + +/** Fades a whole scene, so two overlapping Sequences read as a dissolve. */ +export const Fade: React.FC = ({ children, inFrames = 0, outFrames = 0 }) => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + return {children}; +}; + +export { interpolate }; diff --git a/trailer/src/components/Glyphs.tsx b/trailer/src/components/Glyphs.tsx new file mode 100644 index 0000000..4cee6f8 --- /dev/null +++ b/trailer/src/components/Glyphs.tsx @@ -0,0 +1,43 @@ +import { COLOR } from '../theme'; + +/** The eight inputs the contract replays, one byte each. Matches `ActionCode` in the game. */ +export const ACTION_NAMES = ['up', 'down', 'left', 'right', 'attack', 'retreat', 'descend', 'extract'] as const; + +const ARROW = 'M12 4 L19 13 H14 V20 H10 V13 H5 Z'; + +export const ActionGlyph: React.FC<{ code: number; size?: number; color?: string }> = ({ code, size = 28, color = COLOR.text }) => { + const rotation = [0, 180, -90, 90][code]; + return ( + + {rotation !== undefined && } + {code === 4 && ( + <> + + + + + )} + {code === 5 && } + {code === 6 && } + {code === 7 && } + + ); +}; + +/** The seal the game shows when a run verifies: a triangle with an eye. */ +export const Seal: React.FC<{ size?: number; color?: string }> = ({ size = 96, color = COLOR.gold }) => ( + + + + + + +); + +export const Lock: React.FC<{ size?: number; color?: string }> = ({ size = 34, color = COLOR.cyan }) => ( + + + + + +); diff --git a/trailer/src/components/Panel.tsx b/trailer/src/components/Panel.tsx new file mode 100644 index 0000000..2324a13 --- /dev/null +++ b/trailer/src/components/Panel.tsx @@ -0,0 +1,17 @@ +import { COLOR } from '../theme'; + +/** The game's own panel treatment, so explainer graphics read as part of the product. */ +export const Panel: React.FC<{ children: React.ReactNode; style?: React.CSSProperties; glow?: string }> = ({ children, style, glow }) => ( +
    + {children} +
    +); diff --git a/trailer/src/components/Shot.tsx b/trailer/src/components/Shot.tsx new file mode 100644 index 0000000..c05dbef --- /dev/null +++ b/trailer/src/components/Shot.tsx @@ -0,0 +1,20 @@ +import { Sequence } from 'remotion'; +import { sec } from '../theme'; +import { Fade } from './Footage'; + +interface ShotProps { + window: readonly [number, number]; + /** Frames of dissolve at the head and tail. */ + fadeIn?: number; + fadeOut?: number; + children: React.ReactNode; +} + +/** A scene placed by its start and end in seconds, so the timeline file stays the one source of timing. */ +export const Shot: React.FC = ({ window: [from, to], fadeIn = 0, fadeOut = 0, children }) => ( + + + {children} + + +); diff --git a/trailer/src/components/Type.tsx b/trailer/src/components/Type.tsx new file mode 100644 index 0000000..9770d66 --- /dev/null +++ b/trailer/src/components/Type.tsx @@ -0,0 +1,105 @@ +import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion'; +import { MONO, SANS, SERIF } from '../fonts'; +import { COLOR } from '../theme'; +import { ramp, sceneFade } from '../motion'; + +interface LineProps { + children: React.ReactNode; + size?: number; + color?: string; + weight?: 500 | 600; + tracking?: number; + italic?: boolean; +} + +interface TitleProps extends LineProps { + /** Distance from the bottom edge, or 'center'. */ + at?: 'center' | 'lower' | 'left'; + /** Frames the line takes to resolve. */ + reveal?: number; + fadeOut?: number; + delay?: number; +} + +/** + * A serif line that resolves out of blur. Place it inside a Sequence: its life + * is the Sequence's duration, so the edit stays the single source of timing. + */ +export const Title: React.FC = ({ + children, + size = 84, + color = COLOR.text, + weight = 500, + tracking = 0.01, + italic = false, + at = 'center', + reveal = 26, + fadeOut = 12, + delay = 0, +}) => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const p = ramp(frame, delay, delay + reveal); + const opacity = Math.min(p, sceneFade(frame, durationInFrames, 0, fadeOut)); + return ( + +
    + {children} +
    +
    + ); +}; + +/** Small tracked label, mono, in the game's interface voice. */ +export const Label: React.FC<{ children: React.ReactNode; color?: string; size?: number; opacity?: number }> = ({ + children, + color = COLOR.muted, + size = 22, + opacity = 1, +}) => ( +
    + {children} +
    +); + +export const Body: React.FC<{ children: React.ReactNode; size?: number; color?: string; width?: number }> = ({ + children, + size = 34, + color = COLOR.text, + width = 900, +}) => ( +
    {children}
    +); diff --git a/trailer/src/components/Typed.tsx b/trailer/src/components/Typed.tsx new file mode 100644 index 0000000..0e40e77 --- /dev/null +++ b/trailer/src/components/Typed.tsx @@ -0,0 +1,24 @@ +import { useCurrentFrame, useVideoConfig } from 'remotion'; +import { MONO } from '../fonts'; +import { COLOR } from '../theme'; + +/** Types a line out, one character every `cps` characters per second, with a caret while it works. */ +export const Typed: React.FC<{ text: string; start?: number; cps?: number; color?: string; size?: number }> = ({ + text, + start = 0, + cps = 38, + color = COLOR.text, + size = 30, +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const count = Math.max(0, Math.min(text.length, Math.floor(((frame / fps) - start) * cps))); + const typing = count < text.length && frame / fps >= start; + const caret = Math.floor(frame / 8) % 2 === 0; + return ( + + {text.slice(0, count)} + {count < text.length || caret ? '▌' : ''} + + ); +}; diff --git a/trailer/src/compositions/ShareCardAnimated.tsx b/trailer/src/compositions/ShareCardAnimated.tsx new file mode 100644 index 0000000..df07b99 --- /dev/null +++ b/trailer/src/compositions/ShareCardAnimated.tsx @@ -0,0 +1,99 @@ +import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from 'remotion'; +import plan from '../generated/run-plan.json'; +import { MONO, SERIF } from '../fonts'; +import { COLOR } from '../theme'; +import { SETTLE, ramp } from '../motion'; +import { Embers, Grain, Vignette } from '../components/Atmosphere'; +import { Seal } from '../components/Glyphs'; + +export const CARD_WIDTH = 1200; +export const CARD_HEIGHT = 630; +export const CARD_FRAMES = 150; + +const TEXT_X = 500; + +/** Counts up to `value`, so a stat reads as earned rather than printed. */ +function count(frame: number, from: number, to: number, value: number): number { + return Math.round(interpolate(frame, [from, to], [0, value], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: SETTLE })); +} + +const Stat: React.FC<{ x: number; value: string; label: string; at: number }> = ({ x, value, label, at }) => { + const frame = useCurrentFrame(); + const p = ramp(frame, at, at + 22, SETTLE); + return ( +
    +
    {value}
    +
    {label}
    +
    + ); +}; + +/** + * The shareable result card, in motion. Same layout as the still card the game + * draws (`client/src/render/shareCard.ts`), so the animated post and the saved + * image are recognisably one object. + */ +export const ShareCardAnimated: React.FC = () => { + const frame = useCurrentFrame(); + const bg = 1.02 + 0.05 * (frame / CARD_FRAMES); + const logo = ramp(frame, 0, 24, SETTLE); + const portrait = ramp(frame, 8, 44, SETTLE); + const eyebrow = ramp(frame, 22, 40, SETTLE); + const name = ramp(frame, 28, 58, SETTLE); + const kit = ramp(frame, 44, 70, SETTLE); + const seal = ramp(frame, 96, 120, SETTLE); + const verified = ramp(frame, 104, 130, SETTLE); + return ( + + + + + + + +
    +
    + +
    + +
    EXTRACTED
    +
    + {plan.name} +
    +
    + + Reaver Hook + + + Stone Heart +
    + + + + + + +
    +
    + +
    +
    Sealed on Stellar
    +
    + + + + ); +}; diff --git a/trailer/src/compositions/Trailer30.tsx b/trailer/src/compositions/Trailer30.tsx new file mode 100644 index 0000000..8feefe9 --- /dev/null +++ b/trailer/src/compositions/Trailer30.tsx @@ -0,0 +1,93 @@ +import { AbsoluteFill, Sequence } from 'remotion'; +import { Audio } from '@remotion/media'; +import { staticFile } from 'remotion'; +import { COLOR, sec } from '../theme'; +import { SHOT30, T30 } from '../timeline'; +import { BossBanner } from '../scenes/BossBanner'; +import { Footage, type Rect } from '../components/Footage'; +import { followCamera } from '../camera'; +import { Shot } from '../components/Shot'; +import { Embers, Flash, Grain, Vignette } from '../components/Atmosphere'; +import { Title } from '../components/Type'; +import { Hook } from '../scenes/Hook'; +import { EndCard } from '../scenes/EndCard'; + +const CAMERA = followCamera(820); +const DESCENT: Rect = { x: 221, y: 90, w: 1477, h: 831 }; +const DESCENT_PUSH: Rect = { x: 360, y: 180, w: 1200, h: 675 }; +/** Interface shots keep the card right of centre and leave the left third of the painting for the caption. */ +const CARD_WIDE: Rect = { x: 0, y: 90, w: 1500, h: 844 }; +const CARD_PUSH: Rect = { x: 70, y: 140, w: 1340, h: 754 }; +const VAULT_WIDE: Rect = { x: 0, y: 40, w: 1500, h: 844 }; +const VAULT_PUSH: Rect = { x: 50, y: 90, w: 1360, h: 765 }; + +export const Trailer30: React.FC = () => ( + + +); diff --git a/trailer/src/compositions/Trailer90.tsx b/trailer/src/compositions/Trailer90.tsx new file mode 100644 index 0000000..5432c81 --- /dev/null +++ b/trailer/src/compositions/Trailer90.tsx @@ -0,0 +1,118 @@ +import { AbsoluteFill, Sequence, staticFile } from 'remotion'; +import { Audio } from '@remotion/media'; +import { COLOR, sec } from '../theme'; +import { SHOT90, T90 } from '../timeline'; +import { followCamera } from '../camera'; +import { Footage, type Rect } from '../components/Footage'; +import { Shot } from '../components/Shot'; +import { Flash, Grain, Vignette, Embers } from '../components/Atmosphere'; +import { Title } from '../components/Type'; +import { Hook } from '../scenes/Hook'; +import { BossBanner } from '../scenes/BossBanner'; +import { ChoiceBeat } from '../scenes/ChoiceBeat'; +import { EndCard } from '../scenes/EndCard'; +import { PlayChapter } from '../scenes/explain/PlayChapter'; +import { SeedChapter } from '../scenes/explain/SeedChapter'; +import { ReplayChapter } from '../scenes/explain/ReplayChapter'; +import { RelicsChapter } from '../scenes/explain/RelicsChapter'; +import { VerifyChapter } from '../scenes/explain/VerifyChapter'; + +const CAMERA = followCamera(820); +const DESCENT: Rect = { x: 221, y: 90, w: 1477, h: 831 }; +const DESCENT_PUSH: Rect = { x: 360, y: 180, w: 1200, h: 675 }; +const CARD_WIDE: Rect = { x: 0, y: 90, w: 1500, h: 844 }; +const CARD_PUSH: Rect = { x: 70, y: 140, w: 1340, h: 754 }; +const VAULT_WIDE: Rect = { x: 0, y: 40, w: 1500, h: 844 }; +const VAULT_PUSH: Rect = { x: 50, y: 90, w: 1360, h: 765 }; + +export const Trailer90: React.FC = () => ( + + +); diff --git a/trailer/src/fonts.ts b/trailer/src/fonts.ts new file mode 100644 index 0000000..d2ce329 --- /dev/null +++ b/trailer/src/fonts.ts @@ -0,0 +1,7 @@ +import { loadFont as loadSerif } from '@remotion/google-fonts/CormorantGaramond'; +import { loadFont as loadSans } from '@remotion/google-fonts/Inter'; +import { loadFont as loadMono } from '@remotion/google-fonts/JetBrainsMono'; + +export const SERIF = loadSerif('normal', { weights: ['500', '600'], subsets: ['latin'] }).fontFamily; +export const SANS = loadSans('normal', { weights: ['400', '500'], subsets: ['latin'] }).fontFamily; +export const MONO = loadMono('normal', { weights: ['400', '500'], subsets: ['latin'] }).fontFamily; diff --git a/trailer/src/footage-map.ts b/trailer/src/footage-map.ts new file mode 100644 index 0000000..7441f60 --- /dev/null +++ b/trailer/src/footage-map.ts @@ -0,0 +1,47 @@ +import runCues from './generated/run-cues.json'; + +export interface RunCue { + t: number; + index: number; + floor: number; + mode: string; + hp: number; + relics: number; + events: string[]; + player: { x: number; y: number }; + enemy: { x: number; y: number } | null; +} + +export const RUN_CUES = runCues as RunCue[]; +const CUES = RUN_CUES; + +/** Seconds into run.mp4 of the first input that raised `event` on `floor`. */ +function when(event: string, floor: number, nth = 0): number { + const hits = CUES.filter((c) => c.floor === floor && c.events.includes(event)); + const hit = hits[nth]; + if (!hit) throw new Error(`run.mp4 has no "${event}" number ${nth} on floor ${floor}. Re-run capture/plan.ts and the recorder.`); + return hit.t; +} + +/** + * The beats of the filmed run, read from the cue log the recorder wrote while it + * played, so a re-recorded take moves the edit with it instead of breaking it. + */ +export const RUN = { + floor1Fight: when('combatStart', 1), + floor1Relic: when('relic', 1), + floor1Cleared: when('floorCleared', 1), + floor2Fight: when('combatStart', 2), + floor2Cleared: when('floorCleared', 2), + bossFight: when('combatStart', 3), + bossHit: when('playerHit', 3), + bossSecondHit: when('playerHit', 3, 1), + bossDown: when('enemyDied', 3), + floor3Relic: when('relic', 3), + extracted: when('extracted', 3), +} as const; + +/** Every time the guard on `floor` was struck or struck back, as seconds into run.mp4. */ +export function blows(floor: number): number[] { + return CUES.filter((c) => c.floor === floor && (c.events.includes('enemyHit') || c.events.includes('playerHit'))).map((c) => c.t); +} diff --git a/trailer/src/generated/run-cues.json b/trailer/src/generated/run-cues.json new file mode 100644 index 0000000..a90637a --- /dev/null +++ b/trailer/src/generated/run-cues.json @@ -0,0 +1,817 @@ +[ + { + "t": 12.432, + "index": 0, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 0, + "events": [], + "player": { + "x": 3, + "y": 4 + }, + "enemy": null + }, + { + "t": 12.767, + "index": 1, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 0, + "events": [], + "player": { + "x": 4, + "y": 4 + }, + "enemy": null + }, + { + "t": 13.104, + "index": 2, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 0, + "events": [], + "player": { + "x": 5, + "y": 4 + }, + "enemy": null + }, + { + "t": 13.435, + "index": 3, + "action": 3, + "floor": 1, + "mode": "combat", + "hp": 8, + "relics": 0, + "events": [ + "combatStart" + ], + "player": { + "x": 5, + "y": 4 + }, + "enemy": { + "x": 6, + "y": 4 + } + }, + { + "t": 13.769, + "index": 4, + "action": 4, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 0, + "events": [ + "enemyHit", + "enemyDied" + ], + "player": { + "x": 5, + "y": 4 + }, + "enemy": null + }, + { + "t": 14.494, + "index": 5, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 0, + "events": [], + "player": { + "x": 6, + "y": 4 + }, + "enemy": null + }, + { + "t": 14.825, + "index": 6, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [ + "relic" + ], + "player": { + "x": 7, + "y": 4 + }, + "enemy": null + }, + { + "t": 15.158, + "index": 7, + "action": 2, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 6, + "y": 4 + }, + "enemy": null + }, + { + "t": 15.489, + "index": 8, + "action": 2, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 5, + "y": 4 + }, + "enemy": null + }, + { + "t": 15.821, + "index": 9, + "action": 0, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 5, + "y": 3 + }, + "enemy": null + }, + { + "t": 16.153, + "index": 10, + "action": 0, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 5, + "y": 2 + }, + "enemy": null + }, + { + "t": 16.485, + "index": 11, + "action": 0, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 5, + "y": 1 + }, + "enemy": null + }, + { + "t": 16.819, + "index": 12, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 6, + "y": 1 + }, + "enemy": null + }, + { + "t": 17.151, + "index": 13, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 7, + "y": 1 + }, + "enemy": null + }, + { + "t": 17.486, + "index": 14, + "action": 1, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 7, + "y": 2 + }, + "enemy": null + }, + { + "t": 17.819, + "index": 15, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 8, + "y": 2 + }, + "enemy": null + }, + { + "t": 18.154, + "index": 16, + "action": 1, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 8, + "y": 3 + }, + "enemy": null + }, + { + "t": 18.487, + "index": 17, + "action": 3, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 9, + "y": 3 + }, + "enemy": null + }, + { + "t": 18.819, + "index": 18, + "action": 1, + "floor": 1, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 9, + "y": 4 + }, + "enemy": null + }, + { + "t": 19.152, + "index": 19, + "action": 3, + "floor": 1, + "mode": "choice", + "hp": 8, + "relics": 1, + "events": [ + "floorCleared" + ], + "player": { + "x": 10, + "y": 4 + }, + "enemy": null + }, + { + "t": 19.484, + "index": 20, + "action": 6, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [ + "descended" + ], + "player": { + "x": 2, + "y": 4 + }, + "enemy": null + }, + { + "t": 23.087, + "index": 21, + "action": 0, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 2, + "y": 3 + }, + "enemy": null + }, + { + "t": 23.419, + "index": 22, + "action": 0, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 2, + "y": 2 + }, + "enemy": null + }, + { + "t": 23.753, + "index": 23, + "action": 0, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 2, + "y": 1 + }, + "enemy": null + }, + { + "t": 24.089, + "index": 24, + "action": 3, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 3, + "y": 1 + }, + "enemy": null + }, + { + "t": 24.421, + "index": 25, + "action": 3, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 4, + "y": 1 + }, + "enemy": null + }, + { + "t": 24.752, + "index": 26, + "action": 3, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 5, + "y": 1 + }, + "enemy": null + }, + { + "t": 25.087, + "index": 27, + "action": 3, + "floor": 2, + "mode": "combat", + "hp": 8, + "relics": 1, + "events": [ + "combatStart" + ], + "player": { + "x": 5, + "y": 1 + }, + "enemy": { + "x": 6, + "y": 1 + } + }, + { + "t": 25.421, + "index": 28, + "action": 4, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [ + "enemyHit", + "enemyDied" + ], + "player": { + "x": 5, + "y": 1 + }, + "enemy": null + }, + { + "t": 26.143, + "index": 29, + "action": 3, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 6, + "y": 1 + }, + "enemy": null + }, + { + "t": 26.475, + "index": 30, + "action": 3, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 7, + "y": 1 + }, + "enemy": null + }, + { + "t": 26.806, + "index": 31, + "action": 1, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 7, + "y": 2 + }, + "enemy": null + }, + { + "t": 27.145, + "index": 32, + "action": 3, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 8, + "y": 2 + }, + "enemy": null + }, + { + "t": 27.477, + "index": 33, + "action": 1, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 8, + "y": 3 + }, + "enemy": null + }, + { + "t": 27.808, + "index": 34, + "action": 3, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 9, + "y": 3 + }, + "enemy": null + }, + { + "t": 28.141, + "index": 35, + "action": 1, + "floor": 2, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 9, + "y": 4 + }, + "enemy": null + }, + { + "t": 28.472, + "index": 36, + "action": 3, + "floor": 2, + "mode": "choice", + "hp": 8, + "relics": 1, + "events": [ + "floorCleared" + ], + "player": { + "x": 10, + "y": 4 + }, + "enemy": null + }, + { + "t": 28.805, + "index": 37, + "action": 6, + "floor": 3, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [ + "descended" + ], + "player": { + "x": 2, + "y": 4 + }, + "enemy": null + }, + { + "t": 32.412, + "index": 38, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 3, + "y": 4 + }, + "enemy": null + }, + { + "t": 32.743, + "index": 39, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 4, + "y": 4 + }, + "enemy": null + }, + { + "t": 33.073, + "index": 40, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 8, + "relics": 1, + "events": [], + "player": { + "x": 5, + "y": 4 + }, + "enemy": null + }, + { + "t": 33.404, + "index": 41, + "action": 3, + "floor": 3, + "mode": "combat", + "hp": 8, + "relics": 1, + "events": [ + "combatStart" + ], + "player": { + "x": 5, + "y": 4 + }, + "enemy": { + "x": 6, + "y": 4 + } + }, + { + "t": 33.739, + "index": 42, + "action": 4, + "floor": 3, + "mode": "combat", + "hp": 5, + "relics": 1, + "events": [ + "enemyHit", + "playerHit" + ], + "player": { + "x": 5, + "y": 4 + }, + "enemy": { + "x": 6, + "y": 4 + } + }, + { + "t": 34.465, + "index": 43, + "action": 4, + "floor": 3, + "mode": "combat", + "hp": 1, + "relics": 1, + "events": [ + "enemyHit", + "playerHit" + ], + "player": { + "x": 5, + "y": 4 + }, + "enemy": { + "x": 6, + "y": 4 + } + }, + { + "t": 35.187, + "index": 44, + "action": 4, + "floor": 3, + "mode": "explore", + "hp": 1, + "relics": 1, + "events": [ + "enemyHit", + "enemyDied" + ], + "player": { + "x": 5, + "y": 4 + }, + "enemy": null + }, + { + "t": 35.911, + "index": 45, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 1, + "relics": 1, + "events": [], + "player": { + "x": 6, + "y": 4 + }, + "enemy": null + }, + { + "t": 36.244, + "index": 46, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 1, + "relics": 1, + "events": [], + "player": { + "x": 7, + "y": 4 + }, + "enemy": null + }, + { + "t": 36.578, + "index": 47, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 1, + "relics": 1, + "events": [], + "player": { + "x": 8, + "y": 4 + }, + "enemy": null + }, + { + "t": 36.91, + "index": 48, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 1, + "relics": 2, + "events": [ + "relic" + ], + "player": { + "x": 9, + "y": 4 + }, + "enemy": null + }, + { + "t": 37.243, + "index": 49, + "action": 3, + "floor": 3, + "mode": "explore", + "hp": 1, + "relics": 2, + "events": [], + "player": { + "x": 10, + "y": 4 + }, + "enemy": null + }, + { + "t": 37.579, + "index": 50, + "action": 3, + "floor": 3, + "mode": "result", + "hp": 1, + "relics": 2, + "events": [ + "extracted" + ], + "player": { + "x": 11, + "y": 4 + }, + "enemy": null + } +] \ No newline at end of file diff --git a/trailer/src/generated/run-plan.json b/trailer/src/generated/run-plan.json new file mode 100644 index 0000000..8107c70 --- /dev/null +++ b/trailer/src/generated/run-plan.json @@ -0,0 +1,62 @@ +{ + "seed": 67, + "actions": [ + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 2, + 2, + 0, + 0, + 0, + 3, + 3, + 1, + 3, + 1, + 3, + 1, + 3, + 6, + 0, + 0, + 0, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 1, + 3, + 1, + 3, + 1, + 3, + 6, + 3, + 3, + 3, + 3, + 4, + 4, + 4, + 3, + 3, + 3, + 3, + 3, + 3 + ], + "floor": 3, + "relics": 2, + "score": 4, + "hpLeft": 1, + "maxHp": 8, + "name": "Brannoch" +} \ No newline at end of file diff --git a/trailer/src/generated/sealed-marks.json b/trailer/src/generated/sealed-marks.json new file mode 100644 index 0000000..9e97299 --- /dev/null +++ b/trailer/src/generated/sealed-marks.json @@ -0,0 +1,3 @@ +{ + "sealed": 4.458 +} \ No newline at end of file diff --git a/trailer/src/index.ts b/trailer/src/index.ts new file mode 100644 index 0000000..aa152d6 --- /dev/null +++ b/trailer/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from 'remotion'; +import { Root } from './Root'; + +registerRoot(Root); diff --git a/trailer/src/motion.ts b/trailer/src/motion.ts new file mode 100644 index 0000000..522852a --- /dev/null +++ b/trailer/src/motion.ts @@ -0,0 +1,20 @@ +import { Easing, interpolate } from 'remotion'; + +/** The one easing curve the whole film uses: fast start, long settle. */ +export const SETTLE = Easing.bezier(0.16, 1, 0.3, 1); +export const SMOOTH = Easing.bezier(0.45, 0, 0.25, 1); + +export function ramp(frame: number, from: number, to: number, easing: (t: number) => number = SETTLE): number { + return interpolate(frame, [from, to], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing, + }); +} + +/** Opacity for a scene that fades in and out over `edge` frames at each end. */ +export function sceneFade(frame: number, duration: number, inFrames: number, outFrames: number): number { + const up = inFrames > 0 ? ramp(frame, 0, inFrames, SMOOTH) : 1; + const down = outFrames > 0 ? 1 - ramp(frame, duration - outFrames, duration, SMOOTH) : 1; + return Math.min(up, down); +} diff --git a/trailer/src/scenes/BossBanner.tsx b/trailer/src/scenes/BossBanner.tsx new file mode 100644 index 0000000..0ae3760 --- /dev/null +++ b/trailer/src/scenes/BossBanner.tsx @@ -0,0 +1,46 @@ +import { AbsoluteFill, Sequence, useCurrentFrame } from 'remotion'; +import { MONO, SERIF } from '../fonts'; +import { COLOR } from '../theme'; +import { SETTLE, ramp } from '../motion'; +import { Embers, Flash, Grain, PaintedPlate, Vignette } from '../components/Atmosphere'; + +/** The guardian of the last floor, from the game's own painted intro, with the name struck over it. */ +export const BossBanner: React.FC = () => { + const frame = useCurrentFrame(); + const name = ramp(frame, 6, 30, SETTLE); + const rule = ramp(frame, 12, 40, SETTLE); + const label = ramp(frame, 20, 42, SETTLE); + return ( + + + + + +
    + Floor 3  ·  The Obsidian Crypt +
    +
    + Warden Prime +
    +
    + + + + + + + + ); +}; diff --git a/trailer/src/scenes/ChoiceBeat.tsx b/trailer/src/scenes/ChoiceBeat.tsx new file mode 100644 index 0000000..d36e251 --- /dev/null +++ b/trailer/src/scenes/ChoiceBeat.tsx @@ -0,0 +1,55 @@ +import { AbsoluteFill, useCurrentFrame } from 'remotion'; +import { MONO, SERIF } from '../fonts'; +import { COLOR } from '../theme'; +import { SETTLE, ramp } from '../motion'; + +const OPTIONS = [ + { key: 'Descend', note: 'push deeper', tone: COLOR.cyan }, + { key: 'Extract', note: 'leave with what you hold', tone: COLOR.gold }, +] as const; + +/** The bank-or-push choice that ends every floor, drawn in the game's own button language. */ +export const ChoiceBeat: React.FC = () => { + const frame = useCurrentFrame(); + const head = ramp(frame, 10, 40, SETTLE); + return ( + +
    + Push on, or leave with it. +
    +
    + {OPTIONS.map((option, i) => { + const p = ramp(frame, 34 + i * 12, 62 + i * 12, SETTLE); + return ( +
    +
    {option.key}
    +
    {option.note}
    +
    + ); + })} +
    +
    + ); +}; diff --git a/trailer/src/scenes/EndCard.tsx b/trailer/src/scenes/EndCard.tsx new file mode 100644 index 0000000..78fdc21 --- /dev/null +++ b/trailer/src/scenes/EndCard.tsx @@ -0,0 +1,70 @@ +import { AbsoluteFill, Img, Sequence, staticFile, useCurrentFrame, useVideoConfig } from 'remotion'; +import { MONO, SERIF } from '../fonts'; +import { COLOR } from '../theme'; +import { SETTLE, ramp } from '../motion'; +import { Embers, Flash, Grain, Vignette } from '../components/Atmosphere'; + +interface EndCardProps { + tagline: string; + note: string; +} + +/** The close: the lockup lands on the score's last boom, the question stays on screen. */ +export const EndCard: React.FC = ({ tagline, note }) => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const logo = ramp(frame, 0, 40, SETTLE); + const line = ramp(frame, 26, 62, SETTLE); + const small = ramp(frame, 44, 70, SETTLE); + const out = 1 - ramp(frame, durationInFrames - 12, durationInFrames); + return ( + + + + + + + +
    + {tagline} +
    +
    + {note} +
    +
    + + + + + +
    + ); +}; diff --git a/trailer/src/scenes/Hook.tsx b/trailer/src/scenes/Hook.tsx new file mode 100644 index 0000000..0a465ec --- /dev/null +++ b/trailer/src/scenes/Hook.tsx @@ -0,0 +1,52 @@ +import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from 'remotion'; +import { COLOR } from '../theme'; +import { SETTLE, ramp } from '../motion'; +import { Embers, Grain, Vignette } from '../components/Atmosphere'; + +/** Where the delver's lantern sits in title-key-art.webp, as a fraction of the frame. */ +const LANTERN = { x: 0.617, y: 0.705 } as const; + +/** + * The opening five seconds: no logo, no text. The camera pushes toward the + * lantern while the painting breathes, and the first thing the viewer feels is + * the weight of the shaft, not a brand. + */ +export const Hook: React.FC = () => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const p = frame / (durationInFrames - 1); + const scale = 1.02 + 0.2 * (1 - (1 - p) ** 2.2); + const rise = ramp(frame, 0, 48, SETTLE); + const flicker = 0.62 + 0.2 * Math.sin(frame * 0.31) + 0.12 * Math.sin(frame * 0.83 + 1.7) + 0.06 * Math.sin(frame * 2.1); + return ( + + + +
    + + + + + + ); +}; diff --git a/trailer/src/scenes/explain/PlayChapter.tsx b/trailer/src/scenes/explain/PlayChapter.tsx new file mode 100644 index 0000000..94a7e17 --- /dev/null +++ b/trailer/src/scenes/explain/PlayChapter.tsx @@ -0,0 +1,45 @@ +import { useCurrentFrame } from 'remotion'; +import plan from '../../generated/run-plan.json'; +import { MONO } from '../../fonts'; +import { COLOR } from '../../theme'; +import { ramp } from '../../motion'; +import { ActionStrip } from '../../components/ActionStrip'; +import { Footage } from '../../components/Footage'; +import { Panel } from '../../components/Panel'; +import { followCamera } from '../../camera'; +import { SHOT90 } from '../../timeline'; +import { Stage } from './Stage'; + +const WINDOW_W = 800; +const CAMERA = followCamera(980); + +/** Chapter one: the game runs entirely in the browser, and each input is kept as a byte. */ +export const PlayChapter: React.FC = () => { + const frame = useCurrentFrame(); + const shown = ramp(frame, 20, 52); + const count = ramp(frame, 150, 190); + return ( + +
    + +
    Your browser
    +
    +
    + +
    +
    +
    + +
    + Action log · {plan.actions.length} bytes +
    + +
    +
    +
    + ); +}; diff --git a/trailer/src/scenes/explain/RelicsChapter.tsx b/trailer/src/scenes/explain/RelicsChapter.tsx new file mode 100644 index 0000000..ba6f537 --- /dev/null +++ b/trailer/src/scenes/explain/RelicsChapter.tsx @@ -0,0 +1,52 @@ +import { Img, staticFile, useCurrentFrame } from 'remotion'; +import { MONO } from '../../fonts'; +import { COLOR } from '../../theme'; +import { SETTLE, ramp } from '../../motion'; +import { Lock } from '../../components/Glyphs'; +import { Panel } from '../../components/Panel'; +import { Stage } from './Stage'; + +const RELICS = [ + { code: 'DMCOMMON', art: 'art/relic-common.webp', tone: '#cdd2d8' }, + { code: 'DMRARE', art: 'art/relic-rare.webp', tone: COLOR.cyan }, + { code: 'DMCORE', art: 'art/relic-core.webp', tone: COLOR.gold }, +] as const; + +const FACTS = ['Issuer account locked', 'Only the game contract can mint', 'No sale, no swap, nothing pay to win'] as const; + +/** Chapter four: relics are classic Stellar assets that only a verified extraction can mint. */ +export const RelicsChapter: React.FC = () => { + const frame = useCurrentFrame(); + const card = (i: number): number => ramp(frame, 24 + i * 16, 60 + i * 16, SETTLE); + const fact = (i: number): number => ramp(frame, 120 + i * 22, 150 + i * 22, SETTLE); + return ( + +
    + {RELICS.map((relic, i) => ( + + +
    {relic.code}
    +
    Stellar asset
    +
    + ))} +
    +
    + {FACTS.map((text, i) => ( +
    + + {text} +
    + ))} +
    +
    + ); +}; diff --git a/trailer/src/scenes/explain/ReplayChapter.tsx b/trailer/src/scenes/explain/ReplayChapter.tsx new file mode 100644 index 0000000..4095d74 --- /dev/null +++ b/trailer/src/scenes/explain/ReplayChapter.tsx @@ -0,0 +1,88 @@ +import { useCurrentFrame, useVideoConfig } from 'remotion'; +import plan from '../../generated/run-plan.json'; +import { RUN_CUES } from '../../footage-map'; +import { MONO } from '../../fonts'; +import { COLOR } from '../../theme'; +import { SETTLE, ramp } from '../../motion'; +import { ActionStrip } from '../../components/ActionStrip'; +import { Seal } from '../../components/Glyphs'; +import { Panel } from '../../components/Panel'; +import { Typed } from '../../components/Typed'; +import { Stage } from './Stage'; + +const READ_FROM = 3.0; +const READ_EVERY = 0.07; +/** Seconds into the chapter when the log has been fully read and the final lines begin. */ +const FINAL_AT = 7.2; + +/** Lines that only exist once the whole log has been read. */ +const FINAL_LINES: readonly [string, string][] = [ + ['score', String(plan.score)], + ['result', 'extracted'], +]; + +/** Chapter three: `finish_run` replays the log and derives the result itself. */ +export const ReplayChapter: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const t = frame / fps; + const cursor = Math.min(plan.actions.length - 1, Math.max(0, Math.floor((t - READ_FROM) / READ_EVERY))); + const reading = t >= READ_FROM && t < READ_FROM + plan.actions.length * READ_EVERY + 0.2; + const panel = ramp(frame, 16, 48); + const sealIn = ramp(frame, 9.0 * fps, 10.0 * fps, SETTLE); + const state = RUN_CUES[Math.min(cursor, RUN_CUES.length - 1)]; + const started = t >= READ_FROM; + const liveRows: readonly [string, string][] = started && state + ? [ + ['step', `${cursor + 1} / ${plan.actions.length}`], + ['floor', `${state.floor} / 3`], + ['relics', String(state.relics)], + ['hp left', `${state.hp} / ${plan.maxHp}`], + ] + : [['step', 'waiting for the log']]; + return ( + +
    + +
    + actions · what you send +
    + +
    + + 0.1 ? `rgba(126,240,224,${0.25 * sealIn})` : undefined}> +
    sim.rs · what the contract derives
    +
    +
    + {liveRows.map(([key, value]) => ( +
    + {key} + {value} +
    + ))} + {FINAL_LINES.map(([key, value], i) => { + const at = FINAL_AT + i * 0.5; + return ( +
    + {ramp(t, at, at + 0.1) > 0 ? key : ''} + +
    + ); + })} +
    +
    + +
    +
    +
    + Ranked · relics minted +
    +
    +
    +
    + ); +}; diff --git a/trailer/src/scenes/explain/SeedChapter.tsx b/trailer/src/scenes/explain/SeedChapter.tsx new file mode 100644 index 0000000..ef4ad9e --- /dev/null +++ b/trailer/src/scenes/explain/SeedChapter.tsx @@ -0,0 +1,76 @@ +import { Img, random, staticFile, useCurrentFrame } from 'remotion'; +import { MONO, SERIF } from '../../fonts'; +import { COLOR } from '../../theme'; +import { SETTLE, ramp } from '../../motion'; +import { Panel } from '../../components/Panel'; +import { Stage } from './Stage'; + +const INGREDIENTS = ['ledger sequence', 'ledger time', 'run counter', 'your address'] as const; +const DIGITS = 12; + +/** Chapter two: `start_run` hands the player a seed derived on chain. */ +export const SeedChapter: React.FC = () => { + const frame = useCurrentFrame(); + const panels = ramp(frame, 16, 48); + const call = ramp(frame, 60, 90, SETTLE); + const chips = (i: number): number => ramp(frame, 100 + i * 14, 124 + i * 14); + const settle = ramp(frame, 190, 230); + return ( + +
    + + +
    Brannoch
    +
    Your browser
    +
    + + +
    Soroban contract
    +
    + {INGREDIENTS.map((label, i) => ( +
    + {label} +
    + ))} +
    +
    Seed
    +
    + {Array.from({ length: DIGITS }, (_, i) => { + const locked = settle * DIGITS > i; + const roll = Math.floor(random(`seed-${i}-${Math.floor(frame / 2)}`) * 16).toString(16); + const fixed = Math.floor(random(`seed-fixed-${i}`) * 16).toString(16); + return {frame < 150 ? '·' : locked ? fixed : roll}; + })} +
    +
    + + + + + + +
    start_run
    +
    seed
    +
    + The client never chooses it +
    +
    +
    + ); +}; diff --git a/trailer/src/scenes/explain/Stage.tsx b/trailer/src/scenes/explain/Stage.tsx new file mode 100644 index 0000000..d5e1c49 --- /dev/null +++ b/trailer/src/scenes/explain/Stage.tsx @@ -0,0 +1,58 @@ +import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from 'remotion'; +import { MONO, SANS, SERIF } from '../../fonts'; +import { COLOR } from '../../theme'; +import { SETTLE, ramp, sceneFade } from '../../motion'; +import { Embers, Grain, Vignette } from '../../components/Atmosphere'; + +interface StageProps { + step: string; + heading: string; + body: string; + /** Painted backdrop under the diagram, darkened. */ + plate?: string; + children: React.ReactNode; +} + +/** + * The frame every explainer chapter shares: a darkened painting, the step label + * and heading on the left, the diagram on the right. One layout, so five + * chapters read as one system and the eye knows where to look. + */ +export const Stage: React.FC = ({ step, heading, body, plate = 'art/vault-bg.webp', children }) => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const kicker = ramp(frame, 4, 24, SETTLE); + const title = ramp(frame, 10, 40, SETTLE); + const text = ramp(frame, 26, 56, SETTLE); + return ( + + + + + + +
    +
    {step}
    +
    + {heading} +
    +
    {body}
    +
    +
    {children}
    + + +
    + ); +}; diff --git a/trailer/src/scenes/explain/VerifyChapter.tsx b/trailer/src/scenes/explain/VerifyChapter.tsx new file mode 100644 index 0000000..0e471df --- /dev/null +++ b/trailer/src/scenes/explain/VerifyChapter.tsx @@ -0,0 +1,50 @@ +import { useCurrentFrame, useVideoConfig } from 'remotion'; +import plan from '../../generated/run-plan.json'; +import { MONO } from '../../fonts'; +import { COLOR } from '../../theme'; +import { SETTLE, ramp } from '../../motion'; +import { Panel } from '../../components/Panel'; +import { Typed } from '../../components/Typed'; +import { Stage } from './Stage'; + +const FACTS = [ + ['16 KB', 'contract'], + ['3.6M / 400M', 'CPU, worst case run'], + ['2 transactions', 'per run'], +] as const; + +/** Chapter five: anyone can replay any run, and the cost of doing so is small. */ +export const VerifyChapter: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const t = frame / fps; + const out = ramp(t, 3.4, 4.2, SETTLE); + const chip = (i: number): number => ramp(t, 4.6 + i * 0.35, 5.2 + i * 0.35, SETTLE); + return ( + + +
    read only · free
    +
    + +
    +
    +
    {'→ replayed '}{plan.actions.length}{' inputs'}
    +
    {'floor '}{plan.floor}{' · relics '}{plan.relics}{' · score '}{plan.score}
    +
    nothing written
    +
    +
    +
    + {FACTS.map(([value, label], i) => ( + +
    {value}
    +
    {label}
    +
    + ))} +
    +
    + ); +}; diff --git a/trailer/src/score-spec.ts b/trailer/src/score-spec.ts new file mode 100644 index 0000000..5b3cb83 --- /dev/null +++ b/trailer/src/score-spec.ts @@ -0,0 +1,159 @@ +/** + * What the score has to do, in seconds. The synthesiser in audio/ reads this and + * the compositions never do, so retiming a scene means editing one timeline file + * and one spec, then re-running `bun run audio`. + */ +import { RUN, blows } from './footage-map'; +import plan from './generated/run-plan.json'; +import { SHOT30, SHOT90, T30, T90, filmTime } from './timeline'; + +export type CueKind = 'hit' | 'boom' | 'chime' | 'seal' | 'riser' | 'tick'; + +export interface Cue { + t: number; + kind: CueKind; + /** Hz for chime and seal, seconds for riser. */ + value?: number; + gain?: number; +} + +export interface Heartbeat { + from: number; + to: number; + bpmFrom: number; + bpmTo: number; + gain: number; +} + +export interface Chord { + t: number; + /** MIDI note numbers. */ + notes: number[]; + gain: number; +} + +export interface ScoreSpec { + name: string; + seconds: number; + chords: Chord[]; + /** Overall level of the sub drone and noise bed over time: [seconds, level] points. */ + bed: [number, number][]; + heartbeats: Heartbeat[]; + cues: Cue[]; + fadeOut: number; +} + +const D2 = 38; +const DM: number[] = [D2 + 12, D2 + 19, D2 + 24, D2 + 27]; +const BB: number[] = [D2 + 8, D2 + 15, D2 + 20, D2 + 24]; +const GM: number[] = [D2 + 5, D2 + 12, D2 + 17, D2 + 22]; +const A: number[] = [D2 + 11, D2 + 18, D2 + 23, D2 + 28]; +const D_LIFT: number[] = [D2 + 12, D2 + 19, D2 + 26, D2 + 28]; + +export const SCORE_30: ScoreSpec = { + name: 'score-30', + seconds: 30, + fadeOut: 1.4, + bed: [ + [0, 0], + [1.2, 0.55], + [5, 0.7], + [13.5, 1], + [20, 0.6], + [27.6, 1.1], + [30, 0], + ], + chords: [ + { t: 0, notes: DM, gain: 0.5 }, + { t: 7.5, notes: BB, gain: 0.7 }, + { t: T30.bossBanner[0], notes: GM, gain: 0.9 }, + { t: T30.sealed[0] + 1.4, notes: D_LIFT, gain: 1 }, + { t: T30.end[0], notes: DM, gain: 1 }, + ], + heartbeats: [ + { from: 2.2, to: 5.0, bpmFrom: 44, bpmTo: 50, gain: 0.5 }, + { from: T30.bossBanner[0] + 1.2, to: T30.sealed[0], bpmFrom: 60, bpmTo: 122, gain: 0.9 }, + ], + cues: [ + { t: T30.descent[0] - 0.05, kind: 'riser', value: 4 }, + { t: T30.fight1[0], kind: 'boom', gain: 0.8 }, + ...blows(1).map((b): Cue => ({ t: filmTime(SHOT30.fight1, b), kind: 'hit' })), + { t: filmTime(SHOT30.fight1, RUN.floor1Relic), kind: 'chime', value: 880 }, + ...blows(2).map((b): Cue => ({ t: filmTime(SHOT30.fight2, b), kind: 'hit', gain: 1.1 })), + { t: T30.bossBanner[0], kind: 'boom', gain: 1 }, + ...blows(3).slice(0, 3).map((b, i): Cue => ({ t: filmTime(SHOT30.bossFight, b), kind: 'hit', gain: i === 2 ? 1.1 : 1.4 })), + { t: filmTime(SHOT30.extract, RUN.floor3Relic), kind: 'chime', value: 1319, gain: 1.1 }, + { t: T30.sealed[0] - 0.35, kind: 'riser', value: 1.2 }, + { t: filmTime(SHOT30.sealed, SHOT30.sealed.from + 1.5), kind: 'seal', value: 587 }, + { t: T30.vault[0] + 0.2, kind: 'chime', value: 784, gain: 0.8 }, + { t: T30.end[0], kind: 'boom', gain: 1.2 }, + ], +}; + + +/** Soft clicks for the action log filling in, then being read back. Same timing as the chapter graphics. */ +function ticks(from: number, every: number, count: number): Cue[] { + return Array.from({ length: count }, (_, i): Cue => ({ t: from + i * every, kind: 'tick', gain: 0.5 })); +} + +export const SCORE_90: ScoreSpec = { + name: 'score-90', + seconds: 90, + fadeOut: 2.2, + bed: [ + [0, 0], + [1.2, 0.55], + [5, 0.7], + [14, 0.95], + [27.4, 0.7], + [33, 0.5], + [64, 0.55], + [81, 0.95], + [85, 1.1], + [90, 0], + ], + chords: [ + { t: 0, notes: DM, gain: 0.5 }, + { t: T90.fight1[0], notes: BB, gain: 0.7 }, + { t: T90.bossBanner[0], notes: GM, gain: 0.9 }, + { t: T90.sealed[0] + 1.4, notes: D_LIFT, gain: 1 }, + { t: T90.play[0], notes: DM, gain: 0.55 }, + { t: T90.seed[0], notes: BB, gain: 0.6 }, + { t: T90.replay[0], notes: GM, gain: 0.7 }, + { t: T90.replay[0] + 9.4, notes: D_LIFT, gain: 0.9 }, + { t: T90.relics[0], notes: BB, gain: 0.7 }, + { t: T90.verify[0], notes: A, gain: 0.7 }, + { t: T90.vault[0], notes: D_LIFT, gain: 1 }, + { t: T90.end[0], notes: DM, gain: 1 }, + ], + heartbeats: [ + { from: 2.2, to: 5.0, bpmFrom: 44, bpmTo: 50, gain: 0.5 }, + { from: T90.bossBanner[0] + 1.2, to: T90.sealed[0], bpmFrom: 60, bpmTo: 122, gain: 0.9 }, + ], + cues: [ + { t: T90.descent[0] - 0.05, kind: 'riser', value: 4 }, + { t: T90.fight1[0], kind: 'boom', gain: 0.8 }, + ...blows(1).map((b): Cue => ({ t: filmTime(SHOT90.fight1, b), kind: 'hit' })), + { t: filmTime(SHOT90.fight1, RUN.floor1Relic), kind: 'chime', value: 880 }, + ...blows(2).map((b): Cue => ({ t: filmTime(SHOT90.fight2, b), kind: 'hit', gain: 1.1 })), + { t: T90.bossBanner[0], kind: 'boom', gain: 1 }, + ...blows(3).slice(0, 3).map((b, i): Cue => ({ t: filmTime(SHOT90.bossFight, b), kind: 'hit', gain: i === 2 ? 1.1 : 1.4 })), + { t: filmTime(SHOT90.bossFight, RUN.floor3Relic), kind: 'chime', value: 1319, gain: 1.1 }, + { t: T90.sealed[0] - 0.35, kind: 'riser', value: 1.2 }, + { t: filmTime(SHOT90.sealed, SHOT90.sealed.from + 1.5), kind: 'seal', value: 587 }, + { t: T90.play[0], kind: 'chime', value: 659, gain: 0.6 }, + ...ticks(T90.play[0] + 1.2, 0.085, plan.actions.length), + { t: T90.seed[0], kind: 'chime', value: 659, gain: 0.6 }, + { t: T90.seed[0] + 190 / 30, kind: 'chime', value: 988, gain: 0.7 }, + { t: T90.replay[0], kind: 'chime', value: 659, gain: 0.6 }, + ...ticks(T90.replay[0] + 3.0, 0.07, plan.actions.length), + { t: T90.replay[0] + 9.3, kind: 'seal', value: 587, gain: 0.8 }, + { t: T90.relics[0], kind: 'chime', value: 659, gain: 0.6 }, + { t: T90.relics[0] + 1.0, kind: 'chime', value: 784, gain: 0.5 }, + { t: T90.relics[0] + 1.5, kind: 'chime', value: 988, gain: 0.5 }, + { t: T90.relics[0] + 2.0, kind: 'chime', value: 1319, gain: 0.5 }, + { t: T90.verify[0], kind: 'chime', value: 659, gain: 0.6 }, + { t: T90.vault[0] + 0.2, kind: 'chime', value: 784, gain: 0.8 }, + { t: T90.end[0], kind: 'boom', gain: 1.2 }, + ], +}; diff --git a/trailer/src/theme.ts b/trailer/src/theme.ts new file mode 100644 index 0000000..114a466 --- /dev/null +++ b/trailer/src/theme.ts @@ -0,0 +1,17 @@ +export const FPS = 30; +export const WIDTH = 1920; +export const HEIGHT = 1080; + +/** Tokens copied from the game's stylesheet so the film and the product read as one thing. */ +export const COLOR = { + bg: '#07080e', + text: '#f4efe6', + muted: '#a3988c', + cyan: '#7ef0e0', + cyanDeep: '#143f3c', + gold: '#e7c36a', + danger: '#ff6b5a', +} as const; + +/** Seconds to frames, rounded so scene boundaries always land on a whole frame. */ +export const sec = (seconds: number): number => Math.round(seconds * FPS); diff --git a/trailer/src/timeline.ts b/trailer/src/timeline.ts new file mode 100644 index 0000000..fa783fa --- /dev/null +++ b/trailer/src/timeline.ts @@ -0,0 +1,71 @@ +import { RUN } from './footage-map'; +import sealedMarks from './generated/sealed-marks.json'; + +/** Scene boundaries of the 30 second cut, in seconds. Overlaps are deliberate dissolves. */ +export const T30 = { + total: 30, + hook: [0, 5.0], + descent: [4.6, 8.4], + fight1: [8.4, 11.2], + fight2: [11.2, 14.0], + bossBanner: [14.0, 15.7], + bossFight: [15.7, 17.8], + extract: [17.8, 20.8], + sealed: [20.8, 25.0], + vault: [25.0, 27.2], + end: [27.2, 30], +} as const satisfies Record; + +/** A shot of recorded footage: when it starts in the film and where in the recording it starts. */ +export interface ShotAt { + at: number; + from: number; +} + +/** Seconds into the recording that the sealed screen has been showing before its seal lands. */ +const SEAL_LEAD = 1.5; + +export const SHOT30 = { + fight1: { at: T30.fight1[0], from: RUN.floor1Fight - 0.6 }, + fight2: { at: T30.fight2[0], from: RUN.floor2Fight - 0.5 }, + bossFight: { at: T30.bossFight[0], from: RUN.bossFight - 0.9 }, + extract: { at: T30.extract[0], from: RUN.floor3Relic - 0.8 }, + sealed: { at: T30.sealed[0], from: sealedMarks.sealed - SEAL_LEAD }, + vault: { at: T30.vault[0], from: 1.0 }, +} as const satisfies Record; + +/** The moment in the film at which something that happened at `beat` in the recording is on screen. */ +export const filmTime = (shot: ShotAt, beat: number): number => shot.at + (beat - shot.from); + +/** The 90 second cut. The game comes first, the explanation after it, never before. */ +export const T90 = { + total: 90, + hook: [0, 5.0], + descent: [4.6, 8.2], + fight1: [8.2, 11.4], + fight2: [11.4, 14.0], + choice: [14.0, 18.0], + bossBanner: [18.0, 19.9], + bossFight: [19.9, 23.9], + extract: [23.9, 27.4], + sealed: [27.4, 33.0], + play: [33.0, 42.5], + seed: [42.5, 52.0], + replay: [52.0, 64.0], + relics: [64.0, 74.0], + verify: [74.0, 81.0], + vault: [81.0, 85.0], + end: [85.0, 90.0], +} as const satisfies Record; + +export const SHOT90 = { + fight1: { at: T90.fight1[0], from: RUN.floor1Fight - 0.6 }, + fight2: { at: T90.fight2[0], from: RUN.floor2Fight - 0.5 }, + choice: { at: T90.choice[0], from: RUN.floor2Cleared - 1.6 }, + bossFight: { at: T90.bossFight[0], from: RUN.bossFight - 0.9 }, + extract: { at: T90.extract[0], from: RUN.bossDown }, + sealed: { at: T90.sealed[0], from: sealedMarks.sealed - SEAL_LEAD }, + vault: { at: T90.vault[0], from: 1.0 }, + /** The clip that plays inside the first explainer chapter's browser window. */ + playWindow: { at: T90.play[0], from: RUN.floor1Fight - 1.5 }, +} as const satisfies Record; diff --git a/trailer/tsconfig.json b/trailer/tsconfig.json new file mode 100644 index 0000000..97a92b2 --- /dev/null +++ b/trailer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun"] + }, + "include": ["src", "capture", "scripts", "audio", "remotion.config.ts"] +}