From 454e6ab69ad7cb47878acdc5312f6cb0324f2be1 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 5 Jul 2026 20:57:46 -0400 Subject: [PATCH] feat: add tavern render kit and character entitlements --- .env.example | 5 + .gitignore | 12 + docs/TAVERN_AND_MULTIPLAYER.md | 263 ++++++++++ html/assets/kenney/LICENSE.txt | 8 + .../characters/male0/Male_0_Idle0.png | Bin 0 -> 4903 bytes .../characters/male0/Male_0_Run0.png | Bin 0 -> 4415 bytes .../characters/male0/Male_0_Run1.png | Bin 0 -> 4503 bytes .../characters/male0/Male_0_Run2.png | Bin 0 -> 4632 bytes .../characters/male0/Male_0_Run3.png | Bin 0 -> 4427 bytes .../characters/male0/Male_0_Run4.png | Bin 0 -> 4554 bytes .../characters/male0/Male_0_Run5.png | Bin 0 -> 4703 bytes .../characters/male0/Male_0_Run6.png | Bin 0 -> 4581 bytes .../characters/male0/Male_0_Run7.png | Bin 0 -> 4953 bytes .../characters/male0/Male_0_Run8.png | Bin 0 -> 4743 bytes .../characters/male0/Male_0_Run9.png | Bin 0 -> 4419 bytes .../iso-dungeon/tiles/barrelsStacked_S.png | Bin 0 -> 15886 bytes .../kenney/iso-dungeon/tiles/chair_S.png | Bin 0 -> 3187 bytes .../iso-dungeon/tiles/chestClosed_S.png | Bin 0 -> 4655 bytes .../kenney/iso-dungeon/tiles/planks_S.png | Bin 0 -> 18666 bytes .../iso-dungeon/tiles/stoneWallHalf_S.png | Bin 0 -> 15004 bytes .../kenney/iso-dungeon/tiles/stoneWall_S.png | Bin 0 -> 24419 bytes .../kenney/iso-dungeon/tiles/stone_S.png | Bin 0 -> 21386 bytes html/assets/kenney/roguelikeChar.png | Bin 0 -> 11331 bytes html/assets/kenney/roguelikeIndoor.png | Bin 0 -> 10112 bytes html/assets/kenney/roguelikeSheet.png | Bin 0 -> 46099 bytes html/assets/kenney/roguelikeSheet_meta.json | 18 + html/assets/kenney/tavern_room.json | 1 + html/index.html | 3 + html/js/network/socketHandlers.js | 19 +- html/js/render/asciiRenderer.js | 74 +++ html/js/render/assetPacks.js | 255 ++++++++++ html/js/render/atlas.js | 74 +++ html/js/render/charCustomize.js | 335 +++++++++++++ html/js/render/charSprites.js | 269 +++++++++++ html/js/render/designedRoom.js | 42 ++ html/js/render/fancyRenderer.js | 384 +++++++++++++++ html/js/render/isoRenderer.js | 169 +++++++ html/js/render/renderModes.js | 131 +++++ html/js/render/sceneModel.js | 77 +++ html/js/render/skins.js | 118 +++++ html/js/render/threeRenderer.js | 210 ++++++++ html/js/render/tilePicker.js | 82 ++++ html/js/render/tileRenderer.js | 102 ++++ html/js/ui/paymentUI.js | 88 +++- html/tavern.html | 301 ++++++++++++ scripts/build_kenney_3d_characters.py | 120 +++++ src/.env.example | 8 + src/config/paymentConfig.js | 57 ++- src/game/gameModeManager.js | 206 ++++++-- src/index.js | 7 +- ..._user_appearance_and_pack_entitlements.sql | 34 ++ src/migrations/021_payment_product_grants.sql | 23 + src/multiplayer/Occupant.js | 52 ++ src/multiplayer/Room.js | 214 +++++++++ src/multiplayer/appearance.js | 100 ++++ src/multiplayer/entitlements.js | 127 +++++ src/multiplayer/tavernMap.js | 58 +++ src/network/chat/ChatProvider.js | 45 ++ src/network/chat/SocketChatProvider.js | 61 +++ src/network/chatHandler.js | 32 +- src/network/connectionHandler.js | 6 +- src/network/identityService.js | 112 +++++ src/network/paymentHandlers.js | 61 ++- src/network/socketHandlers.js | 106 ++++- src/network/tavernManager.js | 215 +++++++++ src/package-lock.json | 448 ++++++++++++++++-- src/package.json | 11 +- src/payments/productGrants.js | 86 ++++ test/appearance.test.js | 35 ++ test/chatProvider.test.js | 67 +++ test/entitlements.test.js | 48 ++ test/gameModeManager.test.js | 58 +++ test/identityService.test.js | 98 ++++ test/payment_payout_comprehensive.test.js | 43 ++ test/productGrants.test.js | 43 ++ test/renderScene.test.js | 38 ++ test/tavernManager.test.js | 228 +++++++++ test/tavernRoom.test.js | 86 ++++ 78 files changed, 5839 insertions(+), 134 deletions(-) create mode 100644 .env.example create mode 100644 docs/TAVERN_AND_MULTIPLAYER.md create mode 100644 html/assets/kenney/LICENSE.txt create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Idle0.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run0.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run1.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run2.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run3.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run4.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run5.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run6.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run7.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run8.png create mode 100644 html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run9.png create mode 100644 html/assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png create mode 100644 html/assets/kenney/iso-dungeon/tiles/chair_S.png create mode 100644 html/assets/kenney/iso-dungeon/tiles/chestClosed_S.png create mode 100644 html/assets/kenney/iso-dungeon/tiles/planks_S.png create mode 100644 html/assets/kenney/iso-dungeon/tiles/stoneWallHalf_S.png create mode 100644 html/assets/kenney/iso-dungeon/tiles/stoneWall_S.png create mode 100644 html/assets/kenney/iso-dungeon/tiles/stone_S.png create mode 100644 html/assets/kenney/roguelikeChar.png create mode 100644 html/assets/kenney/roguelikeIndoor.png create mode 100644 html/assets/kenney/roguelikeSheet.png create mode 100644 html/assets/kenney/roguelikeSheet_meta.json create mode 100644 html/assets/kenney/tavern_room.json create mode 100644 html/js/render/asciiRenderer.js create mode 100644 html/js/render/assetPacks.js create mode 100644 html/js/render/atlas.js create mode 100644 html/js/render/charCustomize.js create mode 100644 html/js/render/charSprites.js create mode 100644 html/js/render/designedRoom.js create mode 100644 html/js/render/fancyRenderer.js create mode 100644 html/js/render/isoRenderer.js create mode 100644 html/js/render/renderModes.js create mode 100644 html/js/render/sceneModel.js create mode 100644 html/js/render/skins.js create mode 100644 html/js/render/threeRenderer.js create mode 100644 html/js/render/tilePicker.js create mode 100644 html/js/render/tileRenderer.js create mode 100644 html/tavern.html create mode 100644 scripts/build_kenney_3d_characters.py create mode 100644 src/migrations/020_user_appearance_and_pack_entitlements.sql create mode 100644 src/migrations/021_payment_product_grants.sql create mode 100644 src/multiplayer/Occupant.js create mode 100644 src/multiplayer/Room.js create mode 100644 src/multiplayer/appearance.js create mode 100644 src/multiplayer/entitlements.js create mode 100644 src/multiplayer/tavernMap.js create mode 100644 src/network/chat/ChatProvider.js create mode 100644 src/network/chat/SocketChatProvider.js create mode 100644 src/network/identityService.js create mode 100644 src/network/tavernManager.js create mode 100644 src/payments/productGrants.js create mode 100644 test/appearance.test.js create mode 100644 test/chatProvider.test.js create mode 100644 test/entitlements.test.js create mode 100644 test/identityService.test.js create mode 100644 test/productGrants.test.js create mode 100644 test/renderScene.test.js create mode 100644 test/tavernManager.test.js create mode 100644 test/tavernRoom.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..badce8b --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Single-player dungeon mode. On by default; set false for an instance that only runs +# other modes (e.g. Tavern-only). +SOLO_ENABLED=true +# Real-time multiplayer match mode (not yet available). +MULTIPLAYER_ENABLED=false diff --git a/.gitignore b/.gitignore index 14ad881..553e8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,15 @@ temp/ .DS_Store Thumbs.db TEMP_TODO.md + +# 3D models / generation source artifacts (produced by the sprite pipeline — never commit; +# see ~/src/docs/animated-sprite-pipeline.md) +*.glb +*.fbx +*.blend +*.blend1 +*.obj +*.stl + +# AI-generated / heavy premium assets — hosted & lazy-loaded, not in the code tree +html/assets/generated/ diff --git a/docs/TAVERN_AND_MULTIPLAYER.md b/docs/TAVERN_AND_MULTIPLAYER.md new file mode 100644 index 0000000..627bb36 --- /dev/null +++ b/docs/TAVERN_AND_MULTIPLAYER.md @@ -0,0 +1,263 @@ +# Tavern & Multiplayer — Design & Build Plan + +**Status:** In development. Shared engine scaffold landed under `src/multiplayer/`; unit tests pass. + +Two new, optional game modes. Both are disabled by default and gated by config, so existing +single-player play is unaffected. + +## Shared engine: one Room, two modes + +Both modes are built on a single shared component — a real-time, multi-occupant `Room`: + +- **Tavern** — a peaceful Room: players pick an avatar, walk around, chat, and watch live games. +- **Multiplayer** — the same Room engine with a dungeon, monsters, hazards, and player-vs-player + combat added. + +The engine (`src/multiplayer/`) has no Socket.IO, database, or Wownerogue coupling. A manager +layer owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. Keeping the engine +transport-agnostic makes it unit-testable in isolation and reusable as a standalone package. + +## Modes + +**Tavern** — a social space that reuses the existing lobby and spectator infrastructure. Players +choose an avatar, move around a shared map, and chat. A spectator camera lets them watch live +single-player or multiplayer games from inside the room. + +**Multiplayer** — a shared match Room. Players race to escape a dungeon before the next block, +contend with monsters and environmental hazards, and (later) fight each other. Match rooms enable +occupant collision; the Tavern does not. + +## Operator Policy + +The server operator controls which modes are enabled and, for multiplayer, the economic model. +Configuration is driven through the existing `config/` layer (DB-backed, hot-reloadable) rather +than static env vars alone, so operators can adjust settings without a restart. A later extension +allows per-room settings. + +Which modes are enabled is surfaced to clients in the `game_mode_info` event as +`modes: { solo, tavern, multiplayer }` (from `SOLO_ENABLED` / `TAVERN_ENABLED` / +`MULTIPLAYER_ENABLED`). The client shows or hides entry points from this, so any single mode can +run on its own — including a Tavern-only instance. Solo is on unless explicitly disabled; +Tavern and Multiplayer are opt-in. + +Multiplayer economic models (operator-selected per instance): + +| Model | Description | +|---|---| +| Free | No entry cost or payout. | +| Entry fee, leaderboard only | Players pay to enter; results affect a leaderboard, no payout. | +| Winner-take-pot | Entrants stake into a pot awarded to the winner. | +| Even-return | Entrants recover their stake; only a flat fee is deducted. | + +The default configuration is conservative (free, no payout), matching the current wownerogue +instance. Operators are responsible for ensuring their chosen configuration complies with +applicable laws in their jurisdiction. + +## Avatars + +Avatars are cosmetic. Availability is controlled by Operator Policy — for example, a set available +to everyone plus additional avatars unlocked by credit purchases. The avatar id is stored on the +occupant and broadcast to the room; unlock eligibility is enforced at join time. + +## Character Appearance Contract + +The public identity shape is `appearance: { avatar, tint, equipment }`. The server normalizes this +shape, persists it on `users.appearance`, stores it on the `Occupant` at join time, and broadcasts +it in room snapshots. Renderers use `appearance` as the canonical cosmetic identity; the flat +`avatar` field remains as a compatibility shortcut and as the first discriminator for sprite/skin +loading. + +This contract is intentionally renderer-agnostic: + +- **Top-down grid packs** resolve `avatar` to a base tile/frame and composite `equipment` overlays + above it. `tint` recolours only the authored recolour pixels so outlines, skin, wood, and metal + remain intact. +- **Isometric packs** should keep the same `appearance` ids and supply an isometric resolver for + base frames, direction rows, anchor/feet position, and equipment overlays. If an exact item is + missing, the resolver should fall back by role (`round_shield -> shield -> none`) rather than + changing the saved identity. +- **True 3D packs** should resolve the same ids to GLB assets/material variants/animation clips. + Source models and intermediates stay out of git; shipped GLB or baked sprite outputs are hosted + and lazy-loaded like premium sprite sheets. + +Premium costumes are catalog entries with an entitlement requirement. The client may hide or label +locked choices, but real gating belongs at the server/operator policy layer: identity saves and +join requests normalize unauthorized premium `avatar` ids to an allowed fallback. + +Current baseline premium policy: a player is treated as premium after any successful credits +purchase (`users.total_credits_purchased > 0`). That unlocks the preview premium packs +(`generated-skins`, `iso-dungeon`, and `kenney-3d-characters`) unless a future operator policy +tightens them. The policy shape is already pack-based so products can sell or grant different packs +independently. Operator/admin grants go through +`user_pack_entitlements(user_id, pack_id, source, expires_at, metadata)`, while +`users.premium_level` is reserved for broader tiering. + +## Product Grants + +Payments stay separated from entitlement policy: + +- **Single-game entries** buy one paid game attempt and do not grant premium by default. +- **Credits packages** grant credits and may bundle cosmetic/render packs or a premium tier. +- **Cosmetic products** grant packs or tiers directly and may grant zero credits. + +Catalog configuration supports `grants` on credit packages and a separate `COSMETIC_PRODUCTS` +catalog. A grant payload is normalized to: +`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`. + +Examples: + +```json +{ + "id": "supporter_25", + "label": "25 Credit Supporter Pack", + "credits": 20, + "bonus": 5, + "price": 1000000000000, + "grants": { + "packs": ["generated-skins", "kenney-3d-characters"], + "premiumLevel": "supporter" + } +} +``` + +```json +{ + "id": "pack_3d", + "label": "3D Character Pack", + "price": 250000000000, + "grants": { + "packs": ["kenney-3d-characters"] + } +} +``` + +Confirmed product payments persist the normalized grant on `payments.product_grants`, update +credit balances only when credits are part of the product, and upsert pack ownership in +`user_pack_entitlements`. The client receives the updated entitlement snapshot through +`credits_update` / `identity_update`, so the same premium state gates character saves, tavern joins, +render modes, and later multiplayer rooms. + +## Chat + +The existing chat backend is retained and wrapped behind a `ChatProvider` interface so the backend +can be swapped later without changing callers. The current backend remains the default. + +## Rendering (art pass) + +Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted +into a `Scene` (a tile grid + entities), and any renderer draws it. This lives in +`html/js/render/` and is used by both the Tavern and (planned) the main game. + +Render tiers: +- **ASCII** — monospace glyph grid (canvas). Always-available fallback; accessible. +- **Tiled** — coloured tiles + entity sprites (canvas 2D). The default. Real tilesets/atlases + slot in here later. +- **Fancy** — WebGL via PixiJS: a blurred bloom on entities, flickering warm lights along the + bar, drifting dust motes, a vignette, and smooth movement — all programmatic (no art assets). +- **Iso** — canvas isometric projection using Kenney's Isometric Miniature Dungeon runtime PNGs. +- **3D** — Three.js projection. It loads generated GLB avatars with `idle` / `run` / `jump` + clips when available and falls back to lightweight low-poly avatars during asset iteration. +- Planned: **Fancy ASCII** — shader-lit, animated glyphs. + +Render mode is a per-user choice (persisted) with an operator default. Modes can be marked +**premium** — a cosmetic entitlement intended to be unlocked with credits (the same Operator +Policy pattern as avatar unlocks). Because rendering is client-side, this is a soft/cosmetic gate, +not a hard boundary; premium-only *assets* (special tilesets, shaders) are the real lever if +stronger gating is wanted. The render kit provides the engine and programmatic tiles/effects — +polished pixel-art tilesets, sprite sheets, and shader effects are a separate content effort. + +**CSP note:** PixiJS compiles shaders with `eval`, which the app's strict CSP blocks. The Fancy +tier therefore loads `@pixi/unsafe-eval` (it precompiles shaders without eval) immediately after +`pixi.js`, so no `'unsafe-eval'` is added to the CSP. Any page using the Fancy renderer must +include both scripts, in that order. + +## Asset delivery & performance + +Rules for keeping the tree lean and the client fast: + +- **Heavy source artifacts never go in git.** 3D models and generation intermediates (`*.glb`, + `*.fbx`, `*.blend`, …) are produced by the sprite pipeline (`~/src/docs/animated-sprite-pipeline.md`) + and are gitignored. Only the *outputs* the game loads are considered for shipping. +- **Generated / premium assets are hosted, not committed.** AI-generated premium skins live under + `html/assets/generated/` (gitignored) and are delivered from an external asset host / CDN by URL. + The base tier's Kenney CC0 tiles are small and stay in-repo. +- **Lazy-load by mode / entitlement.** Nothing heavy loads until it's actually needed: + - PixiJS (+ `@pixi/unsafe-eval`) loads only when the **Fancy** mode is first selected — ASCII and + Tiled need no WebGL library. + - A premium skin's sprite sheet is fetched only when that skin is selected and the player is + entitled to it. + - Three.js loads only when the **3D** mode is selected. Generated GLBs are emitted by + `scripts/build_kenney_3d_characters.py` from the local Kenney FBX sources into + `html/assets/generated/3d/` (gitignored). +- **Optimize what ships.** Sprite sheets are delivered as **WebP** (alpha), right-sized to display + resolution, content-hashed with a long cache TTL. (Example: the demo walk sheet is 633 KB as PNG, + 65 KB as WebP — ~10×.) + +## Milestones + +Tavern: +- **T0** — Shared engine scaffold: `Room`, `Occupant`, tavern map, unit tests. (Done) +- **T1** — `TavernManager`: Socket.IO room, server-tick timer, join/move/leave broadcasts; minimal + client (`html/tavern.html`) rendering avatars; gated behind `TAVERN_ENABLED`. (Done) +- **T2a** — `ChatProvider` seam + tavern chat. (Done) +- **T2b** — Spectator camera into live games from the Tavern. +- **T3** — Avatar picker with policy-gated unlocks. + +Multiplayer: +- **M0** — Match Room with a dungeon and shared monsters; players race the block. Includes a load + test with many simulated clients to measure server-tick performance under concurrency. +- **M1** — Player-vs-player combat. +- **M2** — Environmental hazards. +- **M3** — Operator Policy economic models wired in. + +Rendering: +- **R0** — Shared render kit: scene model + ASCII / Tiled / Fancy (PixiJS) renderers + mode + switch, wired to the Tavern. Premium-mode gating scaffolded. (Done) +- **R1** — Wire the main game to the render kit (refactor the `display/` layer behind it). +- **R2** — Real tilesets / sprite sheets and the Fancy-ASCII tier. +- **R3** — Wire premium render modes to credit entitlements (Operator Policy). + +All milestones are additive and config-gated; the single-player path is unchanged. + +## Current status + +`src/multiplayer/` +- `Occupant.js` — a shared-world avatar (position, name, avatar id, facing). +- `Room.js` — map/walkability, server-authoritative one-tile movement, optional occupant + collision, a `tick()` hook for future autonomous systems, and snapshot/full-state serialization. +- `tavernMap.js` — a procedurally generated (rectangular) default tavern layout. + +`src/network/tavernManager.js` — transport + lifecycle: a shared Room, the server-tick timer, +and Socket.IO room broadcasts. Wired into `SocketHandlers` (`tavern_join` / `tavern_move` / +`tavern_leave`, plus disconnect cleanup and shutdown). Inert unless `TAVERN_ENABLED=true`. + +`html/tavern.html` — a minimal self-contained client: connect, choose a name/avatar, enter the +room, walk with arrow keys / WASD, and chat. Renders the map and occupants on a canvas. + +`src/network/chat/` — the `ChatProvider` seam. `ChatProvider` (interface) + `SocketChatProvider` +(default: Socket.IO delivery + Postgres history). Both the global chat (`ChatHandler`) and tavern +chat deliver through it, so the backend can be swapped (e.g. a Nostr channel) without changing +callers. Tavern chat is room-scoped and ephemeral. + +`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic scene + tavern adapter), +`asciiRenderer.js`, `tileRenderer.js`, `fancyRenderer.js` (PixiJS), and `renderModes.js` +(registry, factory with graceful fallback, entitlements, persistence). The tavern client uses it +with a mode toolbar; premium modes are marked and gated by `RK.entitlements`. + +Tests: `test/tavernRoom.test.js` (engine), `test/tavernManager.test.js` (manager incl. chat), +`test/chatProvider.test.js`, `test/renderScene.test.js` (scene adapter), +`test/entitlements.test.js`, `test/identityService.test.js`, and `test/productGrants.test.js`. +End-to-end join/move/leave and chat over a real socket verified out-of-band. + +Payment/product entitlement pieces: +- `src/migrations/020_user_appearance_and_pack_entitlements.sql` — persisted appearance, + premium tier, and pack grants. +- `src/migrations/021_payment_product_grants.sql` — product ids and normalized grants on payments. +- `src/network/identityService.js` — server-side appearance persistence and entitlement-aware + normalization. +- `src/payments/productGrants.js` — product grant normalization/public summaries. +- `src/multiplayer/entitlements.js` — server policy snapshot used by identity, tavern joins, and + payment confirmations. + +Enable locally with `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), then open `/tavern.html` +in two browser tabs. diff --git a/html/assets/kenney/LICENSE.txt b/html/assets/kenney/LICENSE.txt new file mode 100644 index 0000000..9546170 --- /dev/null +++ b/html/assets/kenney/LICENSE.txt @@ -0,0 +1,8 @@ +Art assets in this folder are by Kenney (https://kenney.nl), released under +Creative Commons Zero (CC0 1.0 Universal) — public domain. + +- roguelikeIndoor.png — Kenney "Roguelike Interior Pack" +- roguelikeChar.png — Kenney "Roguelike Characters Pack" + +Tile grid: 16x16 px, 1 px spacing (17 px stride), no margin. +No attribution is legally required; Kenney is credited here with thanks. diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Idle0.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Idle0.png new file mode 100644 index 0000000000000000000000000000000000000000..cc853ea398c30eb4b8819e9f0d87166c1dd44d4b GIT binary patch literal 4903 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run0.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run0.png new file mode 100644 index 0000000000000000000000000000000000000000..2bb1055570a90cbe060c860365fa1d64fbc1e5cc GIT binary patch literal 4415 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run1.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run1.png new file mode 100644 index 0000000000000000000000000000000000000000..998783934e40b5b94b48b9c5363abe2072c52fd8 GIT binary patch literal 4503 zcmd5<=Q|q?+l{o;PbsxW5HVv#h#e9tMr^T1)vghvC`BY~jbDeoYDTHOYE?^>nzbLZ zMX41Yt7_M%ynX+K_uG4axzBa(>s;44AI^vSnVE?m69X>;003Yz(ATj50BHU~Ab|eb z-(|0(Jpce)1DF|EV)O!yI9P6hI9SC&-25D@BD|b3U>*(@CO-DtB7B^(5I!+JE&(of zK6X}=oVdIQA6f6sLLug=ukc0<3I2lI97YH+d127stFyKoT3T2a zCOT6P)bPk$Q%b1miPNh<3tKIOk+QUxzA}h|-9ul&Swq@dP1;dgKEhm`(hxtG=0$MQ zJ86#%vqUDl>wix5O>sf9FfyJFk|%N>Ol5jSTdCJZTD^~T{`ukga#eJat2PY6?}dRY ziVC`D!yGiF-R{Uq3i3LlWOQK=X)vFkK0;FxEXB{|p##Uti`%M8+G@y{C_(k*gpCl; zC^JCR19M?5l7wp5d9cwl@NErU~*vQ&}KkrjeNc*XfRLX4I5;bK^XxV@&Vw~o9$Qc4RZ zswN@u1gB=MDyc0CF;NZ8Yvo;>gs2}z9>T*BYpHID zlqvGmbwtDB>@*{6H1BJ}tdY{TXt~qDLLYsFV639k9fiw@3X+w&7(aI?4q580?~aD0 z-PKBRKppkvnW;%HR}!-C-MN~sIv*=peH$BKs_Lev_|yjZvm+zIT(vI1&9b8kNen=GXJ zey@7+-{yabwy39$TBp6Q`U%$i-LLlBUQUsN>!aDGLgP*}5$u8bi4{xS1niKHATx}%Q>8=5Cv;N6kY2-|Cc%r94ccN!~%)P#s_prvj3or&)+}mpI?2}W|lmA(Z18Qr%le7 zRyi9=ED8@Z8$Pmc+)K8f*HUg=GGyOtd|cs9ChzT?bm(aRZQzlCj+SNk+|K-k&Ey9E zjXJN9Kf)w)4$UkK(e{M=cRjn)z*d)nBOjxkfMn$vR+O|q8Q;hFm zPL;YTKgt@esA^aLF0B=~xD+CIiwWI3HRayBpW1R?YKa45ejgx~ec6vy3!8ig%zM7x z%Gv-B6VJZX2>nr7O=4o2>7LFPb*HsVKHIBMzf|&|je7lg&e8aUwCd~o`ZBnqsG8Kz zSB;6U+J5DL_8LpsdcNF__u58PH5)UU=1QIN4Gmc-ozyRNxnS1nm2!t?l~>?FCGrX<3*3jFaUQA;ACV z`tUFaf7Wt&=ta)g)ebwhRgA~r<7@7wmwa{dt*j8Db-V~sT(QIUQAb`ewojF$VVRnF9_Id>$$n_j*?qI>w!?qJ zuTCR};c>1uLG?poirkJ$Spgq?qdZ;4e!|A#fV#7x5jaui-EDXJH%hrfa5?y=OD6l1 zjs&a)y2kQUwPJMr!I?lJ%pw?|`8qvUVP`@wM6K=f`maa$ZPc6fpNDT$20?DhG5Vo# z_D@GChibJNyrkW|_WpC=rvi3-;#sG^_z!pBPDFeDEZw3<^zn-}p~np?5adYwv9ST)k;gx4-tYk4;<`xY$NKmGy`||CRK^HH>Dt zq~rz#(is0e)^%{mmnjk5sUe@9t-9uL-Wagi_#rWol8Jiz2%w~5$b7C*;9^=b>u7iN z`t$gl3L*repfE`|(w~k`Gko#&!eP-Rm}Wjk*Cg-9o}iehWzMlhKU$a#r4ZaxA1TF? zyc@Q?=y5C{fnZ3p;>_rUoep7&I%cCDriWxQ)o0q@L z`%CTP8Y9RjBr<94;rM=5=glXGG^h|p(Qov(VIh2p5BwDz*6FM8t;OQNCmG#llUewH zspcgkBJq9uB1!!}TXIvG^W?H(5)-nypzwm$B|F9=p`{gzZ`^64;<>7hy-tot$Q>ck5hbC&{)D`4Y?|V!Dx$1D zhUrLntl#@_IWV3km8eC0&}yzhX(xA8gpdg z+QxlE!{AKD_|nmb8_c5x`79cDOt0j`P*AiG-_Zvfdx1PnLTl+dqej5tG$mY0CrBqY`6urGyotuMq;VrML3)qW@PX!j3OLBO-)AiKbi|Wlr zEw2VyXhjkn2z>l^-eM{gpEWa>@Zx}W408pzmcGcbrj|d&pLVklQEKa<_-q4d&a4#p zy+D=vg7Va){@ktkxw&~$>QYNJY^e&Hih`T_Nh)|}FnGf62k$i7L=I@8Pr4Mmh@%IW zxsQ;TazH&44uv{Mo6HQl7c4J)jr8^LncMPh9VL(#Xpz0E`zk9s21Tz1$oVq2bDJ8b zrl!`3Y2AgT?WNmWL(Kuy;2*u*SZB_}SR_eU%{;lxx6MpgiC3X-Latjk2ilRY0;az38Vxo!kuRi%n8SHA#`|1B4yW=m|>41d`EX|!JM&>p3IY>#r`I(5l_7vyNqE~u#ZP(WJq zpw$|N=xDL8Xxl!!As-|v{d)t3$)h!QuV!JjnXnTts9)kOw-c9=-2+uPdnmr8BA7 zg!r9g?|;kEj8@KsZkkjW&KzK+i^IF&ajV4@A5z}x?UwT7+zMqr>-DTUeqy!Z`V+Vn zl-IN{Ch514nA;{`oIp+>@7+`#+1WdM5qauRdvi|FRiwClsDxXS@7CUTdY`XX=Wd-T zUmoI2B}M#u6Aou*XmOn`$`Ung&|I+Mq@ZF*)K+(b`K+;D<6dro?ZsjaZQ<{ z=KZFFSDKw}7eF6iBoUGfkNJP3FiI(Rv_)PVw94Ar{MdOK>YTj#eAIw>yBUAT%*fdH zGP0%4$>G%AmJHs=EFN{uxp{7xqS4m;B?Z>UH#TLqRO@3|~h7*bcmo}TvIcG=Q3yW|0{$l&e%J)? zag|lfvEf$%G1ml@d(wOXP4CW1lSV)PTYFIXxI2Zu0h1t~Cq0DmlxRD9{_5%!B1?$Y z{@h9a79W>aR6VfH`u;c(>_FA4{Wqmk2++3RdG5#7SJMO4S9IrVC-2Nk#Qhp(|wS)_`1 zMVUHk+Jxb1sOLp>gw^#^+KGv)m)5c}G8xv2*=k35D3!(_Dwk9T^t0{|#X{2K{6_4? zyN|nkr!|)+O>EvrXTsFyjv;^gI}HUM&9`2KhKPEs6KOR7KL0R2TKJ%*p^?B#YB~fY zUabK#fN>o28SFI&*x_k0YMgVxSJaZmX2(_*phJj`7L^Ik>ErL3Acr5` z4H30{llPEwlA3qCkzbd1<0<3F+^?g(OV=P5m)+klso;XEo=tUMv8?{B{UdQN&aX3^ zoScF?T?iVVEJ095ROtSUx6z+xU%yA0kk6L4x#W)PcwUWF9G)MC1sap$D2}paVkRqF zrnkg{W3_db)k7ldd~tIT^%SN&|M7u7>ok zrdr11ub;;niLqq`I$ftPD*nMo-&1n!8yzVeT6x`!VTlj0Qe&q84CU4%A?0BnYV>(R vNl@;jZd2{I0Xt5>LzXF)|I;r;RVQB4q5v7?%iSFQS7?AS(W%wG_vC*7X;tv3 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run2.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run2.png new file mode 100644 index 0000000000000000000000000000000000000000..d1fda5c0a12f17a6f351fbb40640a4bb90df333c GIT binary patch literal 4632 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 ZPul#Xrx^TNITrNaTur2|S`ETB>OVZZdDj2{ literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run3.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run3.png new file mode 100644 index 0000000000000000000000000000000000000000..1d769ee6164ac7bb12da11845317f3ec2bfaf170 GIT binary patch literal 4427 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run5.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run5.png new file mode 100644 index 0000000000000000000000000000000000000000..567c6318b99ab1f7d4abe5f14346131a59d1c669 GIT binary patch literal 4703 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run6.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run6.png new file mode 100644 index 0000000000000000000000000000000000000000..269cede62684fd4c9097f6ebd4a700fb03d10b09 GIT binary patch literal 4581 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 R+5caqfsToGou*UV{{VGOvcv!Y literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png b/html/assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png new file mode 100644 index 0000000000000000000000000000000000000000..33b6cde9529b10e193bc1ef1eb2475d3464b434d GIT binary patch literal 15886 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/tiles/chair_S.png b/html/assets/kenney/iso-dungeon/tiles/chair_S.png new file mode 100644 index 0000000000000000000000000000000000000000..871207f3c652abe7c8aba71664b3e65f684eafd8 GIT binary patch literal 3187 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 F`#%rrubKb= literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/tiles/planks_S.png b/html/assets/kenney/iso-dungeon/tiles/planks_S.png new file mode 100644 index 0000000000000000000000000000000000000000..ac0f55ec45a08c061f8de7b066589f2327a78812 GIT binary patch literal 18666 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 SFsf$&0000?e+eF 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/tiles/stoneWall_S.png b/html/assets/kenney/iso-dungeon/tiles/stoneWall_S.png new file mode 100644 index 0000000000000000000000000000000000000000..4ba7ecb47bafa4b849acd12150d674017d23f363 GIT binary patch literal 24419 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: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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/iso-dungeon/tiles/stone_S.png b/html/assets/kenney/iso-dungeon/tiles/stone_S.png new file mode 100644 index 0000000000000000000000000000000000000000..79019782ecb06d97fc2b1cca1b3079255b0f2084 GIT binary patch literal 21386 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/roguelikeChar.png b/html/assets/kenney/roguelikeChar.png new file mode 100644 index 0000000000000000000000000000000000000000..351aabbe7ea77df073a25fd0cee27e17d054aaaf GIT binary patch literal 11331 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/roguelikeIndoor.png b/html/assets/kenney/roguelikeIndoor.png new file mode 100644 index 0000000000000000000000000000000000000000..d66794b80186e551ee4bd5efa6d46f0e3aa31d74 GIT binary patch literal 10112 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/roguelikeSheet.png b/html/assets/kenney/roguelikeSheet.png new file mode 100644 index 0000000000000000000000000000000000000000..32e606ca0596611d977d7ab5b29d6f2b23cfe212 GIT binary patch literal 46099 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 z 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 literal 0 HcmV?d00001 diff --git a/html/assets/kenney/roguelikeSheet_meta.json b/html/assets/kenney/roguelikeSheet_meta.json new file mode 100644 index 0000000..8c02467 --- /dev/null +++ b/html/assets/kenney/roguelikeSheet_meta.json @@ -0,0 +1,18 @@ +{ + "_note": "Generated tile metadata for Kenney's roguelikeSheet (a grid sheet with no shipped names). Labelled by sight from the room's palette. This is the 'metadata layer' themes use; packs that ship XML () are parsed instead of hand-labelled.", + "tile": 16, "spacing": 1, "cols": 57, + "names": { + "23": "barrel", "32": "barrel", "25": "crate", + "28": "counter_sink", "29": "counter", "194": "counter_stove", "677": "sink", + "87": "shelf", "88": "bookshelf", "258": "dresser", "360": "nightstand", + "311": "wardrobe", "368": "wardrobe", "138": "wardrobe", + "129": "bed", "201": "mirror", "482": "clock", "282": "banner", "141": "sack", + "137": "chair", "190": "chair", "191": "table", "197": "bench", "198": "bench", "312": "table", + "156": "window", "158": "window", "159": "window", "215": "window", "216": "window", + "550": "door", "620": "rubble", "585": "bush", "642": "tree", "897": "item", + "119": "floor_wood", "120": "floor_stone", "982": "floor_plank", "983": "floor_plank", "984": "floor_plank", + "922": "rug", "923": "rug", "924": "rug", "979": "rug", "980": "rug", "981": "rug", "1036": "rug", "1037": "rug", "1038": "rug", + "697": "wall", "698": "wall", "699": "wall", "700": "wall", "701": "wall", + "756": "wall", "757": "wall", "758": "wall", "868": "wall", "869": "wall", "871": "wall", "872": "wall", "873": "wall", "874": "wall" + } +} diff --git a/html/assets/kenney/tavern_room.json b/html/assets/kenney/tavern_room.json new file mode 100644 index 0000000..fd0d727 --- /dev/null +++ b/html/assets/kenney/tavern_room.json @@ -0,0 +1 @@ +{"cols": 22, "rows": 16, "tile": 16, "spacing": 1, "tileset": "assets/kenney/roguelikeSheet.png", "tilesetCols": 57, "layerOrder": ["Floor", "Carpet", "Objects", "Details"], "layers": {"Floor": [[700, 698, 698, 698, 698, 698, 698, 698, 701, -1, -1, -1, -1, -1, 700, 698, 698, 698, 698, 698, 698, 701], [756, 873, 873, 873, 873, 873, 873, 873, 757, 698, 698, 698, 698, 698, 758, 873, 873, 873, 873, 873, 873, 756], [756, 868, 868, 868, 868, 868, 868, 868, 872, 873, 873, 873, 873, 873, 874, 868, 868, 868, 868, 868, 868, 756], [756, 120, 120, 120, 120, 120, 120, 120, 869, 868, 868, 868, 868, 868, 871, 120, 120, 120, 120, 120, 120, 756], [756, 120, 120, 120, 120, 120, 120, 120, 119, 119, 119, 119, 119, 119, 119, 120, 120, 120, 120, 120, 120, 756], [756, 120, 120, 120, 120, 120, 120, 120, 119, 119, 119, 119, 119, 119, 119, 120, 120, 120, 120, 120, 120, 756], [757, 698, 698, 698, 698, 698, 698, 701, 119, 119, 119, 119, 119, 119, 119, 700, 698, 698, 698, 698, 698, 758], [872, 873, 873, 873, 873, 873, 873, 756, 119, 119, 119, 119, 119, 119, 119, 756, 873, 873, 873, 873, 873, 874], [869, 868, 868, 868, 868, 868, 868, 756, 119, 119, 119, 119, 119, 119, 119, 756, 868, 868, 868, 868, 868, 871], [-1, -1, -1, -1, -1, -1, -1, 756, 119, 119, 119, 119, 119, 119, 119, 756, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, 756, 119, 119, 119, 119, 119, 119, 119, 756, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, 757, 698, 698, 698, 698, 698, 698, 698, 758, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, 872, 873, 873, 873, 873, 873, 873, 873, 874, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, 869, 868, 868, 868, 868, 868, 868, 868, 871, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, 697, 698, -1, -1, -1, -1, -1, 698, 699, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, 869, 868, -1, -1, -1, -1, -1, 868, 871, -1, -1, -1, -1, -1, -1]], "Carpet": [[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, 922, 923, 923, 923, 924, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, 979, 980, 980, 980, 981, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, 1036, 1037, 1037, 1037, 1038, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, 585, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 585, -1, -1, -1, -1], [-1, -1, -1, -1, -1, 642, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 642, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, 585, -1, -1, -1, 982, 983, 983, 983, 984, -1, -1, -1, 585, -1, -1, -1, -1], [-1, -1, -1, -1, -1, 642, -1, -1, -1, 982, 983, 983, 983, 984, -1, -1, -1, 642, -1, -1, -1, -1]], "Objects": [[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, 311, 156, 201, 156, -1, 156, -1, -1, -1, -1, -1, -1, -1, -1, -1, 482, -1, 88, 87, -1, -1], [-1, 368, -1, 258, -1, -1, 550, -1, -1, 159, -1, 159, -1, 159, -1, -1, -1, 28, 29, 29, -1, -1], [-1, 129, -1, -1, -1, -1, -1, -1, -1, 216, -1, 216, -1, 216, -1, 138, -1, -1, -1, -1, 141, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 677, -1, -1, -1, -1, -1, 194, 360, -1, -1, -1], [-1, 129, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 25, 23, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 190, 190, 190, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, 158, -1, -1, -1, 158, -1, -1, -1, -1, 197, 198, 312, -1, 137, -1, -1, 158, -1, -1, 158, -1], [-1, 215, -1, -1, -1, 215, -1, -1, -1, -1, 191, 191, 191, -1, -1, -1, -1, 215, -1, -1, 215, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, 23, -1, -1, -1, 23, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, 29, 29, 29, 29, 29, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, 158, -1, -1, -1, -1, -1, 158, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, 215, -1, -1, 32, -1, -1, 215, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1]], "Details": [[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, 282, -1, 282, -1, 282, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 620, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 897, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, 23, -1, 23, -1, 23, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1]]}, "solid": [[false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], [false, true, true, true, true, false, true, false, false, false, false, false, false, false, false, false, true, false, true, true, false, false], [false, true, false, true, false, false, true, false, false, true, false, true, false, true, false, false, false, true, true, true, false, false], [false, true, false, false, false, false, false, false, false, true, false, true, false, true, false, true, false, false, false, false, true, false], [false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, true, true, false, false, false], [false, true, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, true, false, false], [false, false, false, false, false, false, false, false, false, false, true, true, true, false, false, false, false, false, false, false, false, false], [false, true, false, false, false, true, false, false, false, false, true, true, true, false, true, false, false, true, false, false, true, false], [false, true, false, false, false, true, false, false, false, false, true, true, true, false, false, false, false, true, false, false, true, false], [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], [false, false, false, false, false, false, false, false, true, false, false, false, false, false, true, false, false, false, false, false, false, false], [false, false, false, false, false, false, false, false, true, false, false, true, false, false, true, false, false, false, false, false, false, false], [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]], "walkable": [[true, true, true, true, true, true, true, true, true, false, false, false, false, false, true, true, true, true, true, true, true, true], [true, false, false, false, false, true, false, true, true, true, true, true, true, true, true, true, false, true, false, false, true, true], [true, false, true, false, true, true, false, true, true, false, true, false, true, false, true, true, true, false, false, false, true, true], [true, false, true, true, true, true, true, true, true, false, true, false, true, false, true, false, true, true, true, true, false, true], [true, true, true, true, true, true, true, true, true, true, true, false, true, true, true, true, true, false, false, true, true, true], [true, false, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, false, false, true, true], [true, true, true, true, true, true, true, true, true, true, false, false, false, true, true, true, true, true, true, true, true, true], [true, false, true, true, true, false, true, true, true, true, false, false, false, true, false, true, true, false, true, true, false, true], [true, false, true, true, true, false, true, true, true, true, false, false, false, true, true, true, true, false, true, true, false, true], [false, false, false, false, false, false, false, true, true, true, true, true, true, true, true, true, false, false, false, false, false, false], [false, false, false, false, false, true, false, true, true, false, true, true, true, false, true, true, false, true, false, false, false, false], [false, false, false, false, false, true, false, true, true, false, false, false, false, false, true, true, false, true, false, false, false, false], [false, false, false, false, false, false, false, true, false, true, true, true, true, true, false, true, false, false, false, false, false, false], [false, false, false, false, false, false, false, true, false, true, true, false, true, true, false, true, false, false, false, false, false, false], [false, false, false, false, false, true, false, true, true, true, true, true, true, true, true, true, false, true, false, false, false, false], [false, false, false, false, false, true, false, true, true, true, true, true, true, true, true, true, false, true, false, false, false, false]]} \ No newline at end of file diff --git a/html/index.html b/html/index.html index bb70398..6674d33 100644 --- a/html/index.html +++ b/html/index.html @@ -108,6 +108,9 @@ + diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index cc5574c..070c2d4 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -683,6 +683,14 @@ const SocketHandlers = { SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // sXMR on stagenet SocketHandlers._explorerTxUrl = data.explorerTxUrl || null; + // Mode availability (Solo / Tavern / Multiplayer). Backward compatible: if the server + // doesn't send `modes`, assume solo-only (the historical behavior). + var modes = data.modes || { solo: true, tavern: false, multiplayer: false }; + SocketHandlers._modes = modes; + if (modes.tavern) { $('#tavernButton').show(); } else { $('#tavernButton').hide(); } + // Tavern-only / no single-player instance: don't surface the solo start button. + if (modes.solo === false) { $('#startButton').hide(); } + // Initialize Smirk auth if enabled and not already initialized if (SocketHandlers._smirkEnabled && typeof SmirkAuth !== 'undefined' && !SmirkAuth._initialized) { SmirkAuth.init(); @@ -769,13 +777,18 @@ const SocketHandlers = { try { // Use human-readable amount (e.g. "1"), NOT atomic units (e.g. 100000000000) var payAmount = data.humanAmount || data.amountFormatted || String(data.amount); + var description = 'Single game entry'; + if (data.paymentType === 'credits_package') { + description = 'Buy ' + (data.package ? data.package.credits : '') + ' credits'; + } else if (data.paymentType === 'cosmetic_pack') { + description = 'Unlock ' + (data.package ? (data.package.label || data.package.id || 'premium pack') : 'premium pack'); + } + await window.smirk.requestPayment({ address: data.address, amount: payAmount, asset: (SocketHandlers._cryptoType || 'WOW').toLowerCase(), - description: data.paymentType === 'credits_package' - ? 'Buy ' + (data.package ? data.package.credits : '') + ' credits' - : 'Single game entry' + description: description }); // User confirmed in Smirk — TX submitted, server monitoring handles the rest diff --git a/html/js/render/asciiRenderer.js b/html/js/render/asciiRenderer.js new file mode 100644 index 0000000..48834a9 --- /dev/null +++ b/html/js/render/asciiRenderer.js @@ -0,0 +1,74 @@ +// AsciiRenderer — draws a Scene as a monospace glyph grid on a 2D canvas. +// The always-available fallback tier: tiny, legible, accessible. +(function (root) { + 'use strict'; + + function AsciiRenderer(host, opts) { + opts = opts || {}; + this.name = 'ascii'; + this.host = host; + this.cell = opts.cell || 22; + this.canvas = document.createElement('canvas'); + this.canvas.className = 'rk-canvas'; + this.ctx = this.canvas.getContext('2d'); + host.appendChild(this.canvas); + } + + AsciiRenderer.prototype.render = function (scene) { + if (!scene) return; + var cell = this.cell, ctx = this.ctx; + this.canvas.width = scene.cols * cell; + this.canvas.height = scene.rows * cell; + + ctx.fillStyle = scene.background || '#000'; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + var glyphFont = (cell - 4) + "px 'Courier New', monospace"; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + ctx.font = glyphFont; + // For a designed room, ASCII shows a floor-plan from the walkability grid. + var room = (window.RK && RK.roomReady && RK.roomReady()) ? RK.activeRoom.desc : null; + for (var y = 0; y < scene.rows; y++) { + for (var x = 0; x < scene.cols; x++) { + if (room) { + var walk = room.walkable[y] && room.walkable[y][x]; + ctx.fillStyle = walk ? '#2f4030' : '#39404c'; + ctx.fillText(walk ? '·' : '#', x * cell + cell / 2, y * cell + cell / 2); + } else { + var def = scene.legend[scene.grid[y][x]] || { char: '?', color: '#555' }; + ctx.fillStyle = def.color; + ctx.fillText(def.char, x * cell + cell / 2, y * cell + cell / 2); + } + } + } + + for (var i = 0; i < scene.entities.length; i++) { + var e = scene.entities[i]; + var cx = e.x * cell + cell / 2, cy = e.y * cell + cell / 2; + ctx.font = glyphFont; + ctx.fillStyle = e.color || '#fff'; + ctx.fillText(e.char || '@', cx, cy); + if (e.you) { + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.strokeRect(e.x * cell + 2, e.y * cell + 2, cell - 4, cell - 4); + } + if (e.label) { + ctx.font = '10px monospace'; + ctx.fillStyle = '#d7dbe0'; + ctx.fillText(e.label, cx, e.y * cell - 1); + } + } + }; + + AsciiRenderer.prototype.destroy = function () { + if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas); + this.canvas = null; + this.ctx = null; + }; + + root.RK = root.RK || {}; + root.RK.AsciiRenderer = AsciiRenderer; +})(window); diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js new file mode 100644 index 0000000..2f5094b --- /dev/null +++ b/html/js/render/assetPacks.js @@ -0,0 +1,255 @@ +// Themes / packs / entitlement policy for the render kit. Packs are resolved by capability +// (top-down, isometric, 3D) so the same `appearance` identity can render through different +// visual projections without changing Room state or join flow. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + RK.TILE_KINDS = ['floor', 'wall', 'bar', 'table']; + var FREE_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' }; + + RK.PACKS = { + 'roguelike-interior': { + id: 'roguelike-interior', + label: 'Kenney Roguelike Interior', + premium: false, + projection: 'topdown' + }, + 'iso-dungeon': { + id: 'iso-dungeon', + label: 'Kenney Isometric Dungeon', + premium: true, + projection: 'iso', + unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + }, + 'kenney-3d-characters': { + id: 'kenney-3d-characters', + label: 'Kenney Animated 3D Characters', + premium: true, + projection: '3d', + unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + }, + 'generated-skins': { + id: 'generated-skins', + label: 'Premium Generated Skins', + premium: true, + projection: 'topdown', + unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + } + }; + + RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {}, credits: 0, totalCreditsPurchased: 0 }; + + RK.setEntitlementSnapshot = function (data) { + data = data || {}; + var prev = RK.entitlements || {}; + var hasTotal = data.totalCreditsPurchased != null || data.total_credits_purchased != null; + var hasPremium = data.premium != null; + var hasPacks = !!data.packs; + var hasCredits = data.credits != null || data.balance != null; + var total = hasTotal + ? Number(data.totalCreditsPurchased || data.total_credits_purchased || 0) + : Number(prev.totalCreditsPurchased || 0); + var credits = hasCredits + ? Number(data.credits != null ? data.credits : data.balance) + : Number(prev.credits || 0); + var premium = hasPremium ? !!data.premium : (hasTotal ? total > 0 : !!prev.premium); + var packs = {}; + for (var id in RK.PACKS) { + var p = RK.PACKS[id]; + if (!p.premium) { + packs[id] = true; + } else if (hasPacks && Object.prototype.hasOwnProperty.call(data.packs, id)) { + packs[id] = !!data.packs[id]; + } else if (!hasTotal && !hasPacks && prev.packs && Object.prototype.hasOwnProperty.call(prev.packs, id)) { + packs[id] = !!prev.packs[id]; + } else { + packs[id] = premium; + } + } + for (var pid in packs) { + if (packs[pid] && RK.PACKS[pid] && RK.PACKS[pid].premium) premium = true; + } + RK.entitlements = { + premium: premium, + level: data.level || (premium ? ((prev.level && prev.level !== 'free') ? prev.level : 'credits') : 'free'), + packs: packs, + credits: credits, + totalCreditsPurchased: total + }; + try { localStorage.setItem('rk_entitlements', JSON.stringify(RK.entitlements)); } catch (_) {} + return RK.entitlements; + }; + + RK.loadEntitlements = function () { + try { + var raw = JSON.parse(localStorage.getItem('rk_entitlements') || 'null'); + if (raw) return RK.setEntitlementSnapshot(raw); + } catch (_) {} + return RK.entitlements; + }; + RK.loadEntitlements(); + + RK.pack = function (id) { return RK.PACKS[id] || null; }; + RK.canUsePack = function (id) { + var p = RK.pack(id); + if (!p) return false; + if (!p.premium) return true; + return !!(RK.entitlements && RK.entitlements.packs && RK.entitlements.packs[id]); + }; + RK.canUseAppearance = function (a) { + if (!a || !a.premium) return true; + return a.pack ? RK.canUsePack(a.pack) : !!(RK.entitlements && RK.entitlements.premium); + }; + + RK.THEMES = { + 'roguelike-interior': { + id: 'roguelike-interior', + label: 'Kenney Roguelike Interior', + tileset: { url: 'assets/kenney/roguelikeIndoor.png', tile: 16, spacing: 1 }, + // Defaults are null until mapped via the tile-picker (localStorage overrides win). + tiles: { floor: null, wall: null, bar: null, table: null } + } + }; + RK.activeThemeId = 'roguelike-interior'; + + RK.theme = function (id) { return RK.THEMES[id || RK.activeThemeId] || null; }; + + // Effective tile map = theme defaults merged with localStorage overrides from the picker. + RK.tileMap = function (id) { + var t = RK.theme(id); if (!t) return {}; + var map = {}; + for (var k in t.tiles) map[k] = t.tiles[k]; + try { + var ov = JSON.parse(localStorage.getItem('rk_tilemap_' + t.id) || '{}'); + for (var kk in ov) map[kk] = ov[kk]; + } catch (_) { /* ignore */ } + return map; + }; + RK.setTile = function (id, kind, col, row) { + var t = RK.theme(id); if (!t) return; + var key = 'rk_tilemap_' + t.id, ov = {}; + try { ov = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {} + ov[kind] = [col, row]; + try { localStorage.setItem(key, JSON.stringify(ov)); } catch (_) {} + }; + + // Lazy tileset atlas per theme. + var atlases = {}; + RK.themeAtlas = function (id) { + var t = RK.theme(id); if (!t || !t.tileset) return null; + if (!atlases[t.id]) { + atlases[t.id] = new RK.TilesetAtlas(t.tileset.url, { tile: t.tileset.tile, spacing: t.tileset.spacing }); + } + return atlases[t.id]; + }; + RK.loadTheme = function (id, cb) { + var a = RK.themeAtlas(id); + if (!a) { if (cb) cb(null); return; } + a.onReady(function () { if (cb) cb(a); }); + }; + + RK.EXTRA_APPEARANCES = { + 'kenney-survivor-male': { + id: 'kenney-survivor-male', + label: 'Survivor', + kind: 'model3d', + premium: true, + pack: 'kenney-3d-characters' + }, + 'kenney-survivor-female': { + id: 'kenney-survivor-female', + label: 'Survivor Scout', + kind: 'model3d', + premium: true, + pack: 'kenney-3d-characters' + } + }; + + function normalizeEquipment(eq) { + eq = eq || {}; + var out = {}; + for (var k in FREE_EQUIPMENT) out[k] = eq[k] || 'none'; + return out; + } + + RK.packAppearances = function () { + var out = []; + for (var id in RK.EXTRA_APPEARANCES) out.push(RK.EXTRA_APPEARANCES[id]); + return out; + }; + + RK.isoAssets = { + pack: 'iso-dungeon', + tile: { w: 84, h: 42, imageW: 92, imageH: 184 }, + tiles: { + floor: 'assets/kenney/iso-dungeon/tiles/stone_S.png', + wall: 'assets/kenney/iso-dungeon/tiles/stoneWall_S.png', + bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png', + table: 'assets/kenney/iso-dungeon/tiles/chestClosed_S.png', + fallback: 'assets/kenney/iso-dungeon/tiles/planks_S.png' + }, + character: { + idle: 'assets/kenney/iso-dungeon/characters/male0/Male_0_Idle0.png', + run: [ + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run0.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run1.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run2.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run3.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run4.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run5.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run6.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run7.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run8.png', + 'assets/kenney/iso-dungeon/characters/male0/Male_0_Run9.png' + ], + imageW: 74, + imageH: 148 + } + }; + + RK.threeAssets = { + pack: 'kenney-3d-characters', + models: { + 'kenney-survivor-male': { + url: 'assets/generated/3d/kenney-animated-characters/survivorMaleB.glb', + clips: { idle: 'idle', run: 'run', jump: 'jump' } + }, + 'kenney-survivor-female': { + url: 'assets/generated/3d/kenney-animated-characters/survivorFemaleA.glb', + clips: { idle: 'idle', run: 'run', jump: 'jump' } + }, + fallback: { + url: 'assets/generated/3d/kenney-animated-characters/survivorMaleB.glb', + clips: { idle: 'idle', run: 'run', jump: 'jump' } + } + } + }; + + RK.resolveAppearance = function (entity, projection) { + entity = entity || {}; + var appearance = entity.appearance || { avatar: entity.avatar || 'default', tint: 'none', equipment: FREE_EQUIPMENT }; + var avatar = appearance.avatar || entity.avatar || 'default'; + if (projection === 'iso') { + return { + projection: 'iso', + pack: 'iso-dungeon', + avatar: avatar, + tint: appearance.tint || 'none', + equipment: normalizeEquipment(appearance.equipment), + character: RK.isoAssets.character + }; + } + if (projection === '3d') { + return { + projection: '3d', + pack: 'kenney-3d-characters', + avatar: avatar, + model: RK.threeAssets.models[avatar] || RK.threeAssets.models.fallback, + tint: appearance.tint || 'none', + equipment: normalizeEquipment(appearance.equipment) + }; + } + return { projection: 'topdown', avatar: avatar, appearance: appearance }; + }; +})(window); diff --git a/html/js/render/atlas.js b/html/js/render/atlas.js new file mode 100644 index 0000000..9035788 --- /dev/null +++ b/html/js/render/atlas.js @@ -0,0 +1,74 @@ +// TilesetAtlas — loads a tileset image and serves individual tiles to either a 2D canvas +// (TileRenderer) or PixiJS (FancyRenderer). Source-agnostic: any pack's sheet loads through it. +(function (root) { + 'use strict'; + + function TilesetAtlas(url, opts) { + opts = opts || {}; + this.url = url; + this.tile = opts.tile || 16; + this.spacing = opts.spacing != null ? opts.spacing : 1; + this.margin = opts.margin || 0; + this.ready = false; + this.cols = 0; + this.rows = 0; + this._cbs = []; + + var self = this; + this.img = new Image(); + this.img.onload = function () { + self.ready = true; + var stride = self.tile + self.spacing; + self.cols = Math.floor((self.img.width - self.margin + self.spacing) / stride); + self.rows = Math.floor((self.img.height - self.margin + self.spacing) / stride); + var cbs = self._cbs; self._cbs = []; + cbs.forEach(function (cb) { try { cb(self); } catch (_) { /* ignore */ } }); + }; + this.img.onerror = function () { self.ready = false; }; + this.img.src = url; + } + + TilesetAtlas.prototype.onReady = function (cb) { + if (this.ready) cb(this); else this._cbs.push(cb); + }; + + // Source pixel of tile (col,row). + TilesetAtlas.prototype.px = function (col, row) { + var stride = this.tile + this.spacing; + return { x: this.margin + col * stride, y: this.margin + row * stride }; + }; + + // Canvas: draw tile (col,row) at (dx,dy) scaled to `size`. Returns false if not ready. + TilesetAtlas.prototype.draw = function (ctx, col, row, dx, dy, size) { + if (!this.ready) return false; + var p = this.px(col, row); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(this.img, p.x, p.y, this.tile, this.tile, dx, dy, size, size); + return true; + }; + + // Canvas: draw by flat tile index (row-major across the tileset's columns). + TilesetAtlas.prototype.drawIndex = function (ctx, idx, dx, dy, size) { + if (idx < 0 || !this.ready) return false; + return this.draw(ctx, idx % this.cols, Math.floor(idx / this.cols), dx, dy, size); + }; + + // Pixi: a cached Texture for tile (col,row), sharing one BaseTexture (nearest-neighbour). + TilesetAtlas.prototype.texture = function (col, row) { + if (typeof PIXI === 'undefined' || !this.ready) return null; + if (!this._base) { + this._base = PIXI.BaseTexture.from(this.img); + this._base.scaleMode = PIXI.SCALE_MODES.NEAREST; + this._texCache = {}; + } + var key = col + ',' + row; + if (!this._texCache[key]) { + var p = this.px(col, row); + this._texCache[key] = new PIXI.Texture(this._base, new PIXI.Rectangle(p.x, p.y, this.tile, this.tile)); + } + return this._texCache[key]; + }; + + root.RK = root.RK || {}; + root.RK.TilesetAtlas = TilesetAtlas; +})(window); diff --git a/html/js/render/charCustomize.js b/html/js/render/charCustomize.js new file mode 100644 index 0000000..e999e7e --- /dev/null +++ b/html/js/render/charCustomize.js @@ -0,0 +1,335 @@ +// Character customization -- a persistent "appearance identity" the player picks once and carries +// across the tavern (and, next, single-player / PvP / dead-kernel). The saved shape is structured +// so premium character packs can add their own tint/equipment catalogs without changing join flow. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + var DEFAULT_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' }; + + function cloneEquipment(eq) { + eq = eq || {}; + var out = {}; + var slots = (RK.CHAR_EQUIPMENT_SLOTS || ['body', 'head', 'shield', 'weapon']); + slots.forEach(function (slot) { out[slot] = eq[slot] || 'none'; }); + return out; + } + + function baseAppearances() { + var list = [ + { id: 'default', label: 'Operator', kind: 'color', color: '#9aa4b2' }, + { id: 'green', label: 'Verdant', kind: 'color', color: '#3fb950' }, + { id: 'amber', label: 'Amber', kind: 'color', color: '#d29922' }, + { id: 'red', label: 'Crimson', kind: 'color', color: '#f85149' } + ]; + for (var cid in (RK.CHARS || {})) { + var c = RK.CHARS[cid]; + list.push({ id: cid, label: c.label || cid, kind: 'char' }); + } + for (var id in (RK.SKINS || {})) { + var s = RK.SKINS[id]; + list.push({ id: id, label: s.label || id, kind: 'skin', premium: !!s.premium, pack: s.pack || 'generated-skins' }); + } + if (RK.packAppearances) { + RK.packAppearances().forEach(function (a) { list.push(a); }); + } + return list; + } + + function baseAppearance(id) { + var all = baseAppearances(); + for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i]; + return all[0]; + } + + // The base appearance catalog. Kept for existing callers that expect a flat list. + RK.appearances = baseAppearances; + + RK.normalizeAppearance = function (input) { + if (input && input.appearance && !input.avatar) input = input.appearance; + if (typeof input === 'string') input = { avatar: input }; + input = input || {}; + + var avatar = input.avatar || input.id || 'default'; + if (!baseAppearance(avatar) || baseAppearance(avatar).id !== avatar) avatar = 'default'; + + var isChar = !!(RK.isChar && RK.isChar(avatar)); + var tint = 'none'; + var equipment = cloneEquipment(DEFAULT_EQUIPMENT); + if (isChar) { + if (RK.CHAR_TINTS && RK.CHAR_TINTS[input.tint]) tint = input.tint; + equipment = RK.normalizeCharEquipment ? RK.normalizeCharEquipment(input.equipment) : cloneEquipment(input.equipment); + } + return { avatar: avatar, tint: tint, equipment: equipment }; + }; + + RK.appearance = function (input) { + var ap = RK.normalizeAppearance(input); + var base = baseAppearance(ap.avatar); + var label = base.label || base.id; + if (base.kind === 'char' && ap.tint && ap.tint !== 'none' && RK.CHAR_TINTS && RK.CHAR_TINTS[ap.tint]) { + label += ' / ' + RK.CHAR_TINTS[ap.tint].label; + } + return { + id: ap.avatar, + label: label, + kind: base.kind, + color: base.color, + premium: !!base.premium, + appearance: ap + }; + }; + + // Identity persistence (client-side for now; server/DB persistence is the next step so it + // follows the player across modes and survives device changes). + RK.saveIdentity = function (appearance) { + try { localStorage.setItem('rk_identity', JSON.stringify({ appearance: RK.normalizeAppearance(appearance) })); } catch (_) { /* ignore */ } + }; + RK.loadIdentity = function () { + try { + var raw = JSON.parse(localStorage.getItem('rk_identity') || 'null'); + if (!raw) return null; + return { appearance: RK.normalizeAppearance(raw.appearance || raw) }; + } catch (_) { return null; } + }; + + function drawBaseThumb(ctx, a, draft, onReady) { + ctx.clearRect(0, 0, 72, 80); + if (a.kind === 'color') { + ctx.beginPath(); ctx.arc(36, 44, 21, 0, Math.PI * 2); + ctx.fillStyle = a.color; ctx.fill(); + ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.stroke(); + return; + } + if (a.kind === 'char') { + var at = RK.charAtlas && RK.charAtlas(); + if (!at) { + RK.loadCharAtlas(onReady); + ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; ctx.fillText('...', 36, 44); + return; + } + var ap = RK.normalizeAppearance(draft || { avatar: a.id }); + ap.avatar = a.id; + if (!RK.drawCharCompositeCanvas(ctx, ap, 1, 8, 70)) { + RK.loadCharAtlas(onReady); + ctx.fillStyle = '#666'; ctx.fillText('...', 36, 44); + } + return; + } + if (a.kind === 'model3d') { + ctx.beginPath(); ctx.arc(36, 43, 21, 0, Math.PI * 2); + ctx.fillStyle = '#64748b'; ctx.fill(); + ctx.fillStyle = '#d7dbe0'; ctx.font = '18px monospace'; ctx.textAlign = 'center'; ctx.fillText('3D', 36, 49); + return; + } + var rec = RK.skinSheet(a.id); + if (!rec) { + var r = RK.loadSkin(a.id); + if (r && r.cbs && onReady) r.cbs.push(onReady); + ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; + ctx.fillText('...', 36, 44); + return; + } + var s = RK.SKINS[a.id]; + var row = (s.dirRows && s.dirRows.down) || 0; + var dh = 72, dw = dh * (s.frameW / s.frameH); + ctx.imageSmoothingEnabled = true; + ctx.drawImage(rec.img, 0, row * s.frameH, s.frameW, s.frameH, 36 - dw / 2, 80 - dh, dw, dh); + + } + + function thumbCanvas(a, getDraft, redraw) { + var cv = document.createElement('canvas'); + cv.width = 72; cv.height = 80; + cv.style.cssText = 'display:block;margin:0 auto;image-rendering:pixelated;'; + var ctx = cv.getContext('2d'); + function draw() { drawBaseThumb(ctx, a, getDraft && getDraft(), draw); } + draw(); + if (redraw) redraw.push(draw); + return cv; + } + + function optionTileCanvas(slot, item, draft, redraw) { + var cv = document.createElement('canvas'); + cv.width = 52; cv.height = 52; + cv.style.cssText = 'display:block;margin:0 auto 4px;image-rendering:pixelated;'; + var ctx = cv.getContext('2d'); + function draw() { + ctx.clearRect(0, 0, 52, 52); + if (!item || item.frame == null) { + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.beginPath(); ctx.moveTo(17, 26); ctx.lineTo(35, 26); ctx.stroke(); + return; + } + var tint = item.tint === false ? 'none' : draft.tint; + if (!RK.drawCharTileCanvas || !RK.drawCharTileCanvas(ctx, item.frame, tint, 2, 2, 48, 48)) { + RK.loadCharAtlas(draw); + ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; ctx.fillText('...', 26, 30); + } + } + draw(); + if (redraw) redraw.push(draw); + return cv; + } + + RK.openCustomize = function (current, onSave) { + var draft = RK.normalizeAppearance(current); + var redrawers = []; + + var wrap = document.createElement('div'); + wrap.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);z-index:9999;overflow:auto;padding:20px;font-family:ui-monospace,monospace;color:#d7dbe0;'; + + var h = document.createElement('div'); + h.textContent = 'Customize your character'; + h.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:12px;'; + wrap.appendChild(h); + + var preview = document.createElement('canvas'); + preview.width = 92; preview.height = 100; + preview.style.cssText = 'display:block;margin:0 0 14px;image-rendering:pixelated;background:#0a0c0f;border:1px solid #2a313a;border-radius:6px;'; + var pctx = preview.getContext('2d'); + wrap.appendChild(preview); + + var grid = document.createElement('div'); + grid.style.cssText = 'display:flex;flex-wrap:wrap;gap:12px;max-width:820px;'; + var cards = {}; + RK.appearances().forEach(function (a) { + var card = document.createElement('div'); + var locked = !!(RK.canUseAppearance && !RK.canUseAppearance(a)); + card.style.cssText = 'width:108px;padding:8px;border:2px solid #2a313a;border-radius:8px;background:#0f1216;cursor:pointer;text-align:center;'; + if (locked) card.style.cssText += 'opacity:0.55;'; + if (locked) card.title = 'Premium pack - buy credits to unlock'; + card.appendChild(thumbCanvas(a, function () { return draft; }, redrawers)); + var lab = document.createElement('div'); + lab.textContent = a.label + (a.premium ? ' *' : ''); + lab.style.cssText = 'font-size:11px;margin-top:6px;color:#c7ccd4;'; + card.appendChild(lab); + card.onclick = function () { + if (RK.canUseAppearance && !RK.canUseAppearance(a)) return; + draft.avatar = a.id; + if (!(RK.isChar && RK.isChar(draft.avatar))) { + draft.tint = 'none'; + draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT); + } + mark(); + renderRows(); + redrawAll(); + }; + grid.appendChild(card); + cards[a.id] = card; + }); + wrap.appendChild(grid); + + var rows = document.createElement('div'); + rows.style.cssText = 'max-width:820px;margin-top:14px;'; + wrap.appendChild(rows); + + function optionButton(label, selected, onclick, canvas) { + var b = document.createElement('button'); + b.type = 'button'; + b.style.cssText = 'width:86px;min-height:78px;border:2px solid ' + (selected ? '#3fb950' : '#2a313a') + ';border-radius:6px;background:#0f1216;color:#c7ccd4;cursor:pointer;padding:6px;font:inherit;font-size:11px;'; + if (canvas) b.appendChild(canvas); + var t = document.createElement('div'); + t.textContent = label; + b.appendChild(t); + b.onclick = onclick; + return b; + } + + function renderRows() { + rows.innerHTML = ''; + if (!(RK.isChar && RK.isChar(draft.avatar))) return; + + var tintRow = document.createElement('div'); + tintRow.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;'; + var tintLabel = document.createElement('div'); + tintLabel.textContent = 'Tint'; + tintLabel.style.cssText = 'width:68px;font-size:12px;color:#8b949e;'; + tintRow.appendChild(tintLabel); + for (var tid in (RK.CHAR_TINTS || {})) { + (function (tint) { + var sw = document.createElement('span'); + sw.style.cssText = 'display:block;width:28px;height:28px;margin:2px auto 5px;border-radius:50%;background:' + (tint.color || '#9aa4b2') + ';border:1px solid rgba(255,255,255,0.35);'; + var b = optionButton(tint.label, draft.tint === tint.id, function () { + draft.tint = tint.id; + renderRows(); + redrawAll(); + }, sw); + b.style.minHeight = '64px'; + tintRow.appendChild(b); + })(RK.CHAR_TINTS[tid]); + } + rows.appendChild(tintRow); + + (RK.CHAR_EQUIPMENT_SLOTS || []).forEach(function (slot) { + var catalog = RK.CHAR_EQUIPMENT[slot] || {}; + var row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;'; + var label = document.createElement('div'); + label.textContent = slot.charAt(0).toUpperCase() + slot.slice(1); + label.style.cssText = 'width:68px;font-size:12px;color:#8b949e;'; + row.appendChild(label); + for (var id in catalog) { + (function (item) { + row.appendChild(optionButton(item.label, draft.equipment[slot] === item.id, function () { + draft.equipment[slot] = item.id; + renderRows(); + redrawAll(); + }, optionTileCanvas(slot, item, draft, redrawers))); + })(catalog[id]); + } + rows.appendChild(row); + }); + } + + function drawPreview() { + pctx.clearRect(0, 0, preview.width, preview.height); + var base = baseAppearance(draft.avatar); + if (base.kind === 'color') { + pctx.beginPath(); pctx.arc(46, 52, 24, 0, Math.PI * 2); + pctx.fillStyle = base.color; pctx.fill(); + pctx.lineWidth = 2; pctx.strokeStyle = 'rgba(255,255,255,0.35)'; pctx.stroke(); + } else if (base.kind === 'char') { + if (!RK.drawCharCompositeCanvas || !RK.drawCharCompositeCanvas(pctx, draft, 6, 8, 80)) RK.loadCharAtlas(redrawAll); + } else if (base.kind === 'model3d') { + pctx.beginPath(); pctx.arc(46, 52, 24, 0, Math.PI * 2); + pctx.fillStyle = '#64748b'; pctx.fill(); + pctx.fillStyle = '#d7dbe0'; pctx.font = '20px monospace'; pctx.textAlign = 'center'; pctx.fillText('3D', 46, 59); + } else { + drawBaseThumb(pctx, base, draft, redrawAll); + } + } + + function redrawAll() { + drawPreview(); + redrawers.forEach(function (fn) { try { fn(); } catch (_) { /* ignore */ } }); + } + + function mark() { + for (var id in cards) cards[id].style.borderColor = (id === draft.avatar) ? '#3fb950' : '#2a313a'; + } + + var bar = document.createElement('div'); + bar.style.cssText = 'margin-top:16px;display:flex;gap:8px;'; + var save = document.createElement('button'); + save.textContent = 'Save'; + save.style.cssText = 'font:inherit;padding:8px 16px;border:0;border-radius:6px;background:#3fb950;color:#08130a;font-weight:600;cursor:pointer;'; + save.onclick = function () { + var saved = RK.normalizeAppearance(draft); + RK.saveIdentity(saved); + document.body.removeChild(wrap); + if (onSave) onSave(saved); + }; + var cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + cancel.style.cssText = 'font:inherit;padding:8px 16px;border:1px solid #2a313a;border-radius:6px;background:#0f1216;color:#d7dbe0;cursor:pointer;'; + cancel.onclick = function () { document.body.removeChild(wrap); }; + bar.appendChild(save); bar.appendChild(cancel); + wrap.appendChild(bar); + + document.body.appendChild(wrap); + mark(); + renderRows(); + redrawAll(); + }; +})(window); diff --git a/html/js/render/charSprites.js b/html/js/render/charSprites.js new file mode 100644 index 0000000..623f32a --- /dev/null +++ b/html/js/render/charSprites.js @@ -0,0 +1,269 @@ +// Roguelike top-down characters (free tier). Each character is a SINGLE sprite on Kenney's +// character sheet (adjacent columns are DIFFERENT characters, not walk frames). We animate +// entirely procedurally: a vertical bob + squash while moving, horizontal flip when facing left. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + var COLS = 54, TILE = 16, SHEET = 'assets/kenney/roguelikeChar.png'; + var EQUIP_SLOTS = ['body', 'head', 'shield', 'weapon']; + + // Kenney's sheet includes recolour-friendly purple/magenta pixels. We remap only those + // exact source pixels so skin, outlines, wood, and metal keep their authored colours. + var TINT_SOURCE = { + '160,132,196': 0, // dark purple + '181,159,206': 1, + '199,182,220': 2, + '213,201,228': 3, + '255,153,204': 2 // hot-pink accent used by a few weapon overlays + }; + + RK.CHAR_TINTS = { + none: { id: 'none', label: 'Natural', color: null, ramp: null }, + rose: { id: 'rose', label: 'Rose', color: '#d85c78', ramp: ['#8f3d54', '#b84f6b', '#d96f8c', '#f4aac0'] }, + teal: { id: 'teal', label: 'Teal', color: '#37aaa5', ramp: ['#1f6360', '#287d79', '#37aaa5', '#99dee3'] }, + moss: { id: 'moss', label: 'Moss', color: '#7bad2c', ramp: ['#4f721d', '#639535', '#7bad2c', '#95ce3e'] }, + gold: { id: 'gold', label: 'Gold', color: '#d29922', ramp: ['#8d6640', '#b08052', '#d29922', '#eee3b3'] }, + violet: { id: 'violet', label: 'Violet', color: '#a084c4', ramp: ['#74558f', '#8f6eb0', '#a084c4', '#d5c9e4'] }, + ash: { id: 'ash', label: 'Ash', color: '#878787', ramp: ['#4f4f4f', '#646464', '#878787', '#cacaca'] } + }; + + RK.CHAR_EQUIPMENT = { + body: { + none: { id: 'none', label: 'None', frame: null }, + robe: { id: 'robe', label: 'Robe', frame: 14 }, + jerkin: { id: 'jerkin', label: 'Jerkin', frame: 122 }, + mail: { id: 'mail', label: 'Mail', frame: 230 }, + sash: { id: 'sash', label: 'Sash', frame: 440 } + }, + head: { + none: { id: 'none', label: 'None', frame: null }, + hood: { id: 'hood', label: 'Hood', frame: 24, tint: false }, + helm: { id: 'helm', label: 'Helm', frame: 454, tint: false }, + horns: { id: 'horns', label: 'Horns', frame: 354, tint: false }, + cap: { id: 'cap', label: 'Cap', frame: 462 } + }, + shield: { + none: { id: 'none', label: 'None', frame: null }, + round: { id: 'round', label: 'Round', frame: 254 }, + kite: { id: 'kite', label: 'Kite', frame: 304 }, + tower: { id: 'tower', label: 'Tower', frame: 202 }, + buckler:{ id: 'buckler', label: 'Buckler', frame: 411, tint: false } + }, + weapon: { + none: { id: 'none', label: 'None', frame: null }, + staff: { id: 'staff', label: 'Staff', frame: 154 }, + sword: { id: 'sword', label: 'Sword', frame: 369, tint: false }, + axe: { id: 'axe', label: 'Axe', frame: 375, tint: false }, + bow: { id: 'bow', label: 'Bow', frame: 107, tint: false } + } + }; + RK.CHAR_EQUIPMENT_SLOTS = EQUIP_SLOTS.slice(); + + // Curated humanoid characters (verified non-empty, by sight), one tile index each. + RK.CHARS = { + 'char-villager': { id: 'char-villager', label: 'Villager', frame: 270 }, + 'char-elder': { id: 'char-elder', label: 'Elder', frame: 271 }, + 'char-barbarian': { id: 'char-barbarian', label: 'Barbarian', frame: 324 }, + 'char-monk': { id: 'char-monk', label: 'Monk', frame: 379 }, + 'char-ranger': { id: 'char-ranger', label: 'Ranger', frame: 432 }, + 'char-bard': { id: 'char-bard', label: 'Bard', frame: 486 }, + 'char-rogue': { id: 'char-rogue', label: 'Rogue', frame: 540 }, + 'char-merchant': { id: 'char-merchant', label: 'Merchant', frame: 541 }, + 'char-wizard': { id: 'char-wizard', label: 'Wizard', frame: 595 }, + 'char-goblin': { id: 'char-goblin', label: 'Goblin', frame: 162 } + }; + RK.isChar = function (id) { return !!(id && RK.CHARS[id]); }; + + function validTint(id) { + return (id && RK.CHAR_TINTS[id]) ? id : 'none'; + } + + function validEquipment(slot, id) { + var catalog = RK.CHAR_EQUIPMENT[slot] || {}; + return (id && catalog[id]) ? id : 'none'; + } + + RK.normalizeCharEquipment = function (equipment) { + equipment = equipment || {}; + var out = {}; + EQUIP_SLOTS.forEach(function (slot) { + out[slot] = validEquipment(slot, equipment[slot]); + }); + return out; + }; + + RK.charAppearance = function (e) { + e = e || {}; + var ap = e.appearance || {}; + var avatar = e.avatar || ap.avatar || 'char-villager'; + return { + avatar: avatar, + tint: validTint(ap.tint), + equipment: RK.normalizeCharEquipment(ap.equipment) + }; + }; + + RK.charRenderKey = function (e) { + var ap = RK.charAppearance(e); + return [ + ap.avatar, ap.tint, + ap.equipment.body, ap.equipment.head, ap.equipment.shield, ap.equipment.weapon + ].join('|'); + }; + + var atlas = null; + var tintedTiles = {}; + var tintedTextures = {}; + RK.loadCharAtlas = function (cb) { + if (!atlas) atlas = new RK.TilesetAtlas(SHEET, { tile: TILE, spacing: 1 }); + atlas.onReady(function () { if (cb) cb(atlas); }); + return atlas; + }; + RK.charAtlas = function () { return (atlas && atlas.ready) ? atlas : null; }; + + var last = {}; // procedural animation state per entity + RK.charFrame = function (ch, e, nowMs) { + var st = last[e.id] || (last[e.id] = { x: e.x, y: e.y, t: 0 }); + if (e.x !== st.x || e.y !== st.y) { st.x = e.x; st.y = e.y; st.t = nowMs; } + var moving = (nowMs - st.t) < 300; + return { + idx: ch.frame, + bob: moving ? -Math.abs(Math.sin(nowMs / 100)) * 2.5 : Math.sin(nowMs / 480) * 0.6, + squash: moving ? 1 + Math.sin(nowMs / 100) * 0.07 : 1, + flip: e.facing === 'left', + moving: moving + }; + }; + + RK.charTexture = function (idx) { + var a = RK.charAtlas(); + if (!a) return null; + return a.texture(idx % a.cols, Math.floor(idx / a.cols)); + }; + + function hexToRgb(hex) { + var n = parseInt(String(hex).replace('#', ''), 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + } + + function tintedTileCanvas(idx, tintId) { + var a = RK.charAtlas(); + tintId = validTint(tintId); + if (!a || tintId === 'none') return null; + var tint = RK.CHAR_TINTS[tintId]; + if (!tint || !tint.ramp) return null; + var key = idx + '|' + tintId; + if (tintedTiles[key]) return tintedTiles[key]; + + var cv = document.createElement('canvas'); + cv.width = cv.height = TILE; + var ctx = cv.getContext('2d'); + var src = a.px(idx % a.cols, Math.floor(idx / a.cols)); + ctx.drawImage(a.img, src.x, src.y, TILE, TILE, 0, 0, TILE, TILE); + + var img = ctx.getImageData(0, 0, TILE, TILE); + var ramp = tint.ramp.map(hexToRgb); + for (var i = 0; i < img.data.length; i += 4) { + var k = img.data[i] + ',' + img.data[i + 1] + ',' + img.data[i + 2]; + var ri = TINT_SOURCE[k]; + if (ri == null || img.data[i + 3] === 0) continue; + var rgb = ramp[Math.min(ri, ramp.length - 1)]; + img.data[i] = rgb.r; img.data[i + 1] = rgb.g; img.data[i + 2] = rgb.b; + } + ctx.putImageData(img, 0, 0); + tintedTiles[key] = cv; + return cv; + } + + RK.charTileTexture = function (idx, tintId) { + tintId = validTint(tintId); + if (tintId === 'none') return RK.charTexture(idx); + if (typeof PIXI === 'undefined') return null; + var key = idx + '|' + tintId; + if (tintedTextures[key]) return tintedTextures[key]; + var cv = tintedTileCanvas(idx, tintId); + if (!cv) return RK.charTexture(idx); + var tx = PIXI.Texture.from(cv); + if (tx.baseTexture) tx.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST; + tintedTextures[key] = tx; + return tx; + }; + + RK.drawCharTileCanvas = function (ctx, idx, tintId, dx, dy, dw, dh) { + var a = RK.charAtlas(); + if (!a) return false; + var tinted = tintedTileCanvas(idx, tintId); + ctx.imageSmoothingEnabled = false; + if (tinted) { + ctx.drawImage(tinted, 0, 0, TILE, TILE, dx, dy, dw, dh); + return true; + } + var src = a.px(idx % a.cols, Math.floor(idx / a.cols)); + ctx.drawImage(a.img, src.x, src.y, TILE, TILE, dx, dy, dw, dh); + return true; + }; + + RK.charOverlayParts = function (appearance) { + appearance = appearance || {}; + var tint = validTint(appearance.tint); + var eq = RK.normalizeCharEquipment(appearance.equipment); + var out = []; + EQUIP_SLOTS.forEach(function (slot) { + var item = RK.CHAR_EQUIPMENT[slot][eq[slot]]; + if (!item || item.frame == null) return; + out.push({ slot: slot, id: item.id, frame: item.frame, tint: item.tint === false ? 'none' : tint }); + }); + return out; + }; + + // Canvas draw (TileRenderer + thumbnails). Returns false (and lazy-loads) if not ready. + RK.drawCharCanvas = function (ctx, e, cell, nowMs) { + var a = RK.charAtlas(); + if (!a) { RK.loadCharAtlas(); return false; } + var appearance = RK.charAppearance(e); + var ch = RK.CHARS[appearance.avatar]; + if (!ch) return false; + var f = RK.charFrame(ch, e, nowMs); + var dw = cell * 1.8, dh = dw * f.squash; + var cx = e.x * cell + cell / 2; + var dy = (e.y * cell + cell) - dh + f.bob; // feet at tile bottom, squashed upward + ctx.imageSmoothingEnabled = false; + if (e.you) { + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.ellipse(cx, e.y * cell + cell - 2, dw * 0.3, cell * 0.16, 0, 0, Math.PI * 2); ctx.stroke(); + } + function drawComposite(dx, dy2) { + RK.drawCharTileCanvas(ctx, f.idx, appearance.tint, dx, dy2, dw, dh); + RK.charOverlayParts(appearance).forEach(function (part) { + RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh); + }); + } + if (f.flip) { + ctx.save(); + ctx.translate(cx + dw / 2, dy); ctx.scale(-1, 1); + drawComposite(0, 0); + ctx.restore(); + } else { + drawComposite(cx - dw / 2, dy); + } + if (e.label) { + ctx.fillStyle = '#d7dbe0'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; + ctx.fillText(e.label, cx, dy - 2); + } + return true; + }; + + RK.drawCharCompositeCanvas = function (ctx, appearance, dx, dy, size) { + var a = RK.charAtlas(); + if (!a) { RK.loadCharAtlas(); return false; } + var ap = RK.charAppearance({ avatar: appearance && appearance.avatar, appearance: appearance }); + var ch = RK.CHARS[ap.avatar]; + if (!ch) return false; + RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy, size, size); + RK.charOverlayParts(ap).forEach(function (part) { + RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy, size, size); + }); + return true; + }; +})(window); diff --git a/html/js/render/designedRoom.js b/html/js/render/designedRoom.js new file mode 100644 index 0000000..6765b1c --- /dev/null +++ b/html/js/render/designedRoom.js @@ -0,0 +1,42 @@ +// Designed room — loads an imported Tiled room (from tmx_import.py): layered tile-index grids +// + a tileset, and draws the layers (back-to-front) beneath the avatars. The server uses the +// same room's walkability/dims for collision, so visuals and movement match. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + RK.activeRoom = null; + + RK.loadRoom = function (url, cb) { + if (!url) { if (cb) cb(null); return; } + fetch(url).then(function (r) { return r.json(); }).then(function (desc) { + var atlas = new RK.TilesetAtlas(desc.tileset, { tile: desc.tile, spacing: desc.spacing }); + atlas.onReady(function () { + RK.activeRoom = { desc: desc, atlas: atlas }; + if (cb) cb(RK.activeRoom); + }); + }).catch(function (e) { + if (root.console) console.warn('room load failed:', e.message); + if (cb) cb(null); + }); + }; + + RK.roomReady = function () { + return !!(RK.activeRoom && RK.activeRoom.atlas && RK.activeRoom.atlas.ready); + }; + + // Draw the room's layers (back-to-front) onto a 2D canvas at the given cell size. + RK.drawRoomCanvas = function (ctx, cell) { + if (!RK.roomReady()) return; + var d = RK.activeRoom.desc, a = RK.activeRoom.atlas; + ctx.imageSmoothingEnabled = false; + for (var li = 0; li < d.layerOrder.length; li++) { + var grid = d.layers[d.layerOrder[li]]; + for (var y = 0; y < d.rows; y++) { + var row = grid[y]; + for (var x = 0; x < d.cols; x++) { + if (row[x] >= 0) a.drawIndex(ctx, row[x], x * cell, y * cell, cell); + } + } + } + }; +})(window); diff --git a/html/js/render/fancyRenderer.js b/html/js/render/fancyRenderer.js new file mode 100644 index 0000000..82f0abe --- /dev/null +++ b/html/js/render/fancyRenderer.js @@ -0,0 +1,384 @@ +// FancyRenderer — WebGL tier via PixiJS (v7). Beyond flat shapes: a real blurred bloom on +// entities, warm flickering lights along the bar, drifting dust motes, and a vignette — all +// programmatic (no art assets). Requires a global PIXI (loaded from CDN, with @pixi/unsafe-eval +// so shaders compile under a strict CSP); the factory falls back to tiled if PIXI is missing. +(function (root) { + 'use strict'; + + function colorNum(hex) { return parseInt(String(hex).replace('#', ''), 16) || 0; } + function facingDelta(f) { + if (f === 'up') return { x: 0, y: -1 }; + if (f === 'left') return { x: -1, y: 0 }; + if (f === 'right') return { x: 1, y: 0 }; + return { x: 0, y: 1 }; + } + // A soft radial-gradient texture (used for lights, motes, and the vignette). + function radialTexture(size, stops) { + var c = document.createElement('canvas'); + c.width = c.height = size; + var ctx = c.getContext('2d'); + var g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + stops.forEach(function (s) { g.addColorStop(s[0], s[1]); }); + ctx.fillStyle = g; + ctx.fillRect(0, 0, size, size); + return PIXI.Texture.from(c); + } + + function FancyRenderer(host, opts) { + if (typeof PIXI === 'undefined') throw new Error('PIXI not loaded'); + opts = opts || {}; + this.name = 'fancy'; + this.host = host; + this.cell = opts.cell || 26; + this.app = new PIXI.Application({ backgroundColor: 0x0a0c0f, antialias: true }); + this.app.view.className = 'rk-canvas'; + host.appendChild(this.app.view); + + this.tileLayer = new PIXI.Container(); + this.lightLayer = new PIXI.Container(); + this.entityLayer = new PIXI.Container(); + this.particleLayer = new PIXI.Container(); + this.app.stage.addChild(this.tileLayer, this.lightLayer, this.entityLayer, this.particleLayer); + this.vignette = null; + + this.texLight = radialTexture(128, [[0, 'rgba(255,190,110,0.95)'], [1, 'rgba(255,190,110,0)']]); + this.texMote = radialTexture(64, [[0, 'rgba(255,240,210,0.9)'], [1, 'rgba(255,240,210,0)']]); + this.texVig = radialTexture(256, [[0, 'rgba(0,0,0,0)'], [0.6, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,0.62)']]); + + this.sprites = {}; + this.lights = []; + this.particles = []; + this._sizeKey = null; + this._t = 0; + + var self = this; + this._tick = function (dt) { self._animate(dt); }; + this.app.ticker.add(this._tick); + } + + FancyRenderer.prototype.render = function (scene) { + if (!scene || !this.app) return; + var cell = this.cell, w = scene.cols * cell, h = scene.rows * cell; + this.app.renderer.resize(w, h); + var key = scene.cols + 'x' + scene.rows; + var hasRoom = !!(window.RK && RK.roomReady && RK.roomReady()); + if (key !== this._sizeKey || hasRoom !== this._hadRoom) { + this._buildTiles(scene); + this._buildLights(scene); + this._buildVignette(w, h); + this._seedParticles(w, h); + this._sizeKey = key; + this._hadRoom = hasRoom; + } + this._syncEntities(scene); + }; + + FancyRenderer.prototype._buildTiles = function (scene) { + this.tileLayer.removeChildren(); + var cell = this.cell; + // A designed room is loaded — draw its layered tiles as sprites from the room atlas. + if (window.RK && RK.roomReady && RK.roomReady()) { + var d = RK.activeRoom.desc, a = RK.activeRoom.atlas; + for (var li = 0; li < d.layerOrder.length; li++) { + var grid = d.layers[d.layerOrder[li]]; + for (var ry = 0; ry < d.rows; ry++) { + for (var rx = 0; rx < d.cols; rx++) { + var idx = grid[ry][rx]; + if (idx < 0) continue; + var tex = a.texture(idx % a.cols, Math.floor(idx / a.cols)); + if (!tex) continue; + var sp = new PIXI.Sprite(tex); + sp.x = rx * cell; sp.y = ry * cell; sp.width = cell; sp.height = cell; + this.tileLayer.addChild(sp); + } + } + } + return; + } + for (var y = 0; y < scene.rows; y++) { + for (var x = 0; x < scene.cols; x++) { + var def = scene.legend[scene.grid[y][x]] || { color: '#333' }; + var g = new PIXI.Graphics(); + g.beginFill(colorNum(def.color)); + g.drawRoundedRect(0, 0, cell - 1, cell - 1, 4); + g.endFill(); + if (def.solid) { + g.beginFill(0xffffff, 0.09); + g.drawRoundedRect(0, 0, cell - 1, Math.max(2, cell * 0.2), 4); + g.endFill(); + g.beginFill(0x000000, 0.22); + g.drawRect(0, cell - 3, cell - 1, 3); + g.endFill(); + } + g.x = x * cell; + g.y = y * cell; + this.tileLayer.addChild(g); + } + } + }; + + FancyRenderer.prototype._buildLights = function (scene) { + this.lightLayer.removeChildren(); + this.lights = []; + var cell = this.cell; + for (var y = 0; y < scene.rows; y++) { + for (var x = 0; x < scene.cols; x++) { + if (scene.grid[y][x] !== 'bar' || (x % 3) !== 0) continue; // sample the bar + var s = new PIXI.Sprite(this.texLight); + s.anchor.set(0.5); + s.blendMode = PIXI.BLEND_MODES.ADD; + s.x = x * cell + cell / 2; + s.y = y * cell + cell / 2; + s.scale.set(cell * 2.4 / 128); + s.alpha = 0.5; + this.lightLayer.addChild(s); + this.lights.push({ s: s, base: 0.5, phase: Math.random() * 6.28 }); + } + } + }; + + FancyRenderer.prototype._buildVignette = function (w, h) { + if (this.vignette) { this.app.stage.removeChild(this.vignette); this.vignette.destroy(); } + this.vignette = new PIXI.Sprite(this.texVig); + this.vignette.anchor.set(0.5); + this.vignette.x = w / 2; + this.vignette.y = h / 2; + this.vignette.width = w * 1.25; + this.vignette.height = h * 1.35; + this.app.stage.addChild(this.vignette); // drawn on top of everything + }; + + FancyRenderer.prototype._seedParticles = function (w, h) { + this.particleLayer.removeChildren(); + this.particles = []; + var n = Math.min(60, Math.max(12, Math.round(w * h / 9000))); + for (var i = 0; i < n; i++) { + var p = new PIXI.Sprite(this.texMote); + p.anchor.set(0.5); + p.blendMode = PIXI.BLEND_MODES.ADD; + p.x = Math.random() * w; + p.y = Math.random() * h; + p.scale.set(0.12 + Math.random() * 0.18); + p.alpha = 0.15 + Math.random() * 0.2; + this.particleLayer.addChild(p); + this.particles.push({ + s: p, w: w, h: h, ph: Math.random() * 6.28, + vx: (Math.random() - 0.5) * 0.25, vy: -(0.05 + Math.random() * 0.15) + }); + } + }; + + FancyRenderer.prototype._makeSprite = function (e) { + var cell = this.cell, r = cell * 0.34, col = colorNum(e.color); + var c = new PIXI.Container(); + + var glow = new PIXI.Graphics(); + glow.beginFill(col, 0.6); + glow.drawCircle(0, 0, r * 1.4); + glow.endFill(); + try { + if (!PIXI.BlurFilter) throw new Error('no blur'); + glow.filters = [new PIXI.BlurFilter(8)]; // real soft bloom + } catch (_) { + // No blur available: fake it with layered alpha rings. + glow.clear(); + [[1.9, 0.05], [1.5, 0.10], [1.15, 0.18]].forEach(function (ring) { + glow.beginFill(col, ring[1]); + glow.drawCircle(0, 0, r * ring[0]); + glow.endFill(); + }); + } + glow.blendMode = PIXI.BLEND_MODES.ADD; + c.addChild(glow); + c._glow = glow; + + var body = new PIXI.Graphics(); + body.beginFill(col); + body.drawCircle(0, 0, r); + body.endFill(); + var d = facingDelta(e.facing); + body.beginFill(0x0a0c0f); + body.drawCircle(d.x * r * 0.55, d.y * r * 0.55, cell * 0.08); + body.endFill(); + c.addChild(body); + + if (e.you) { + var ring = new PIXI.Graphics(); + ring.lineStyle(2, 0xffffff, 0.9); + ring.drawCircle(0, 0, r + 2); + c.addChild(ring); + } + if (e.label) { + var label = new PIXI.Text(e.label, { fontFamily: 'monospace', fontSize: 11, fill: 0xd7dbe0 }); + label.anchor.set(0.5, 1); + label.y = -cell * 0.5; + c.addChild(label); + } + return c; + }; + + // A premium animated-skin sprite (Pixi). Frames are swapped each tick in _animate. + FancyRenderer.prototype._makeSkinSprite = function (e) { + var s = window.RK.SKINS[e.avatar]; + var c = new PIXI.Container(); + var h = this.cell * (s.scale || 1.8); + // Warm backlight so a dark character reads against the dark room. + var glow = new PIXI.Graphics(); + [[h * 0.55, 0.05], [h * 0.4, 0.10], [h * 0.28, 0.16]].forEach(function (rr) { + glow.beginFill(0xffe2b4, rr[1]); glow.drawCircle(0, 0, rr[0]); glow.endFill(); + }); + glow.blendMode = PIXI.BLEND_MODES.ADD; + glow.y = this.cell * 0.5 - h * 0.42; + c.addChild(glow); + var spr = new PIXI.Sprite(window.RK.skinTexture(e.avatar, 0, 0)); + spr.anchor.set(0.5, 0.9); + spr.height = h; spr.width = h * (s.frameW / s.frameH); + spr.y = this.cell * 0.5; // feet at the tile's bottom + c.addChild(spr); + c._skinSpr = spr; c._skinId = e.avatar; + if (e.you) { + var ring = new PIXI.Graphics(); + ring.lineStyle(2, 0xffffff, 0.85); + ring.drawEllipse(0, this.cell * 0.5, this.cell * 0.3, this.cell * 0.16); + c.addChild(ring); + } + if (e.label) { + var label = new PIXI.Text(e.label, { fontFamily: 'monospace', fontSize: 11, fill: 0xd7dbe0 }); + label.anchor.set(0.5, 1); label.y = this.cell * 0.5 - h - 2; + c.addChild(label); + } + return c; + }; + + function configureCharPart(spr, h) { + spr.anchor.set(0.5, 0.9); + spr.height = h; + spr.width = h; + return spr; + } + + // A roguelike character sprite (procedural walk + composited equipment), animated in _animate. + FancyRenderer.prototype._makeCharSprite = function (e) { + var appearance = window.RK.charAppearance(e); + var ch = window.RK.CHARS[appearance.avatar]; + var c = new PIXI.Container(); + var h = this.cell * 1.8; + var body = new PIXI.Container(); + body.y = this.cell * 0.5; + var baseTex = window.RK.charTileTexture(ch.frame, appearance.tint); + if (baseTex) body.addChild(configureCharPart(new PIXI.Sprite(baseTex), h)); + window.RK.charOverlayParts(appearance).forEach(function (part) { + var tex = window.RK.charTileTexture(part.frame, part.tint); + if (tex) body.addChild(configureCharPart(new PIXI.Sprite(tex), h)); + }); + c.addChild(body); + c._charBody = body; c._charId = appearance.avatar; c._charKey = window.RK.charRenderKey(e); + if (e.you) { + var ring = new PIXI.Graphics(); + ring.lineStyle(2, 0xffffff, 0.85); + ring.drawEllipse(0, this.cell * 0.5, this.cell * 0.3, this.cell * 0.16); + c.addChild(ring); + } + if (e.label) { + var label = new PIXI.Text(e.label, { fontFamily: 'monospace', fontSize: 11, fill: 0xd7dbe0 }); + label.anchor.set(0.5, 1); label.y = this.cell * 0.5 - h - 2; + c.addChild(label); + } + return c; + }; + + FancyRenderer.prototype._syncEntities = function (scene) { + var cell = this.cell, seen = {}, RKr = window.RK; + for (var i = 0; i < scene.entities.length; i++) { + var e = scene.entities[i]; + seen[e.id] = true; + var wantSkin = !!(RKr && RKr.isSkin && RKr.isSkin(e.avatar)); + var wantChar = !!(RKr && RKr.isChar && RKr.isChar(e.avatar)); + if (wantSkin && !RKr.skinSheet(e.avatar)) RKr.loadSkin(e.avatar); // lazy fetch + if (wantChar && !RKr.charAtlas()) RKr.loadCharAtlas(); + var kind = (wantSkin && RKr.skinSheet(e.avatar)) ? 'skin' + : (wantChar && RKr.charAtlas()) ? 'char' + : 'circle'; + var renderKey = (kind === 'char' && RKr.charRenderKey) ? RKr.charRenderKey(e) : e.avatar; + var s = this.sprites[e.id]; + if (s && (s.kind !== kind || s.renderKey !== renderKey)) { // sheet loaded or appearance changed -> rebuild + this.entityLayer.removeChild(s.c); s.c.destroy({ children: true }); + s = null; delete this.sprites[e.id]; + } + if (!s) { + var c = (kind === 'skin') ? this._makeSkinSprite(e) + : (kind === 'char') ? this._makeCharSprite(e) + : this._makeSprite(e); + c.x = e.x * cell + cell / 2; + c.y = e.y * cell + cell / 2; + this.entityLayer.addChild(c); + s = this.sprites[e.id] = { c: c, kind: kind, renderKey: renderKey }; + } + s.tx = e.x; s.ty = e.y; s.e = e; + } + for (var id in this.sprites) { + if (!seen[id]) { + this.entityLayer.removeChild(this.sprites[id].c); + this.sprites[id].c.destroy({ children: true }); + delete this.sprites[id]; + } + } + }; + + FancyRenderer.prototype._animate = function (dt) { + this._t += dt; + var t = this._t, cell = this.cell, k = Math.min(1, 0.22 * dt), i, nowMs = Date.now(); + + for (var id in this.sprites) { + var s = this.sprites[id]; + var tx = s.tx * cell + cell / 2, ty = s.ty * cell + cell / 2; + s.c.x += (tx - s.c.x) * k; + s.c.y += (ty - s.c.y) * k; + if (s.c._charBody && s.e) { + var cf = window.RK.charFrame(window.RK.CHARS[s.c._charId], s.e, nowMs); + s.c._charBody.scale.x = cf.flip ? -1 : 1; + s.c._charBody.scale.y = cf.squash; + s.c._charBody.y = this.cell * 0.5 + cf.bob; + } else if (s.c._skinSpr && s.e) { + var sk = window.RK.SKINS[s.c._skinId]; + var fr = window.RK.skinFrame(sk, s.e, nowMs); + var tex = window.RK.skinTexture(s.c._skinId, fr.row, fr.col); + if (tex) s.c._skinSpr.texture = tex; + } else if (s.c._glow) { + s.c._glow.scale.set(1 + Math.sin(t * 0.09 + s.tx) * 0.16); + s.c._glow.alpha = 0.65 + Math.sin(t * 0.11) * 0.25; + } + } + for (i = 0; i < this.lights.length; i++) { + var L = this.lights[i]; + L.s.alpha = L.base + Math.sin(t * 0.25 + L.phase) * 0.14 + Math.sin(t * 0.9 + L.phase * 1.7) * 0.06; + } + for (i = 0; i < this.particles.length; i++) { + var p = this.particles[i]; + p.s.x += p.vx * dt; + p.s.y += p.vy * dt; + if (p.s.y < -8) { p.s.y = p.h + 8; p.s.x = Math.random() * p.w; } + if (p.s.x < -8) p.s.x = p.w + 8; else if (p.s.x > p.w + 8) p.s.x = -8; + p.s.alpha = 0.12 + (Math.sin(t * 0.15 + p.ph) + 1) * 0.12; + } + }; + + FancyRenderer.prototype.destroy = function () { + try { + if (this.app) { + this.app.ticker.remove(this._tick); + this.app.destroy(true, { children: true }); + } + } catch (_) { /* ignore */ } + [this.texLight, this.texMote, this.texVig].forEach(function (tx) { + try { if (tx) tx.destroy(true); } catch (_) { /* ignore */ } + }); + this.app = null; + this.sprites = {}; + this.lights = []; + this.particles = []; + }; + + root.RK = root.RK || {}; + root.RK.FancyRenderer = FancyRenderer; +})(window); diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js new file mode 100644 index 0000000..8f4c55b --- /dev/null +++ b/html/js/render/isoRenderer.js @@ -0,0 +1,169 @@ +// IsoRenderer — canvas isometric projection using Kenney's Isometric Miniature Dungeon pack. +// It draws the same Scene as the top-down renderers, preserving the shared Room contract. +(function (root) { + 'use strict'; + + function IsoRenderer(host, opts) { + opts = opts || {}; + this.name = 'iso'; + this.host = host; + this.assets = (root.RK && RK.isoAssets) || {}; + this.tileW = opts.tileW || (this.assets.tile && this.assets.tile.w) || 84; + this.tileH = opts.tileH || (this.assets.tile && this.assets.tile.h) || 42; + this.imageW = opts.imageW || (this.assets.tile && this.assets.tile.imageW) || 92; + this.imageH = opts.imageH || (this.assets.tile && this.assets.tile.imageH) || 184; + this.canvas = document.createElement('canvas'); + this.canvas.className = 'rk-canvas'; + this.ctx = this.canvas.getContext('2d'); + this.cache = {}; + this.last = {}; + this.lastScene = null; + this._raf = null; + this._animating = false; + host.appendChild(this.canvas); + this._preload(); + } + + IsoRenderer.prototype._load = function (url) { + if (!url) return null; + if (this.cache[url]) return this.cache[url]; + var rec = this.cache[url] = { ready: false, img: new Image() }; + var self = this; + rec.img.onload = function () { rec.ready = true; self._invalidate(); }; + rec.img.onerror = function () { rec.error = true; }; + rec.img.src = url; + return rec; + }; + + IsoRenderer.prototype._invalidate = function () { + if (this._raf || !this.lastScene) return; + var self = this; + this._raf = requestAnimationFrame(function () { + self._raf = null; + if (self.lastScene) self.render(self.lastScene); + }); + }; + + IsoRenderer.prototype._preload = function () { + var tiles = this.assets.tiles || {}; + for (var k in tiles) this._load(tiles[k]); + var ch = this.assets.character || {}; + this._load(ch.idle); + (ch.run || []).forEach(this._load.bind(this)); + }; + + IsoRenderer.prototype._project = function (x, y, originX, originY) { + return { + x: originX + (x - y) * this.tileW / 2, + y: originY + (x + y) * this.tileH / 2 + }; + }; + + IsoRenderer.prototype._drawImage = function (img, cx, baseY, w, h) { + this.ctx.drawImage(img, cx - w / 2, baseY - h, w, h); + }; + + IsoRenderer.prototype._tileUrl = function (kind) { + var tiles = this.assets.tiles || {}; + return tiles[kind] || tiles.fallback || tiles.floor; + }; + + IsoRenderer.prototype._charFrame = function (e, now) { + var key = e.id || 'anon'; + var st = this.last[key] || (this.last[key] = { x: e.x, y: e.y, t: 0 }); + if (st.x !== e.x || st.y !== e.y) { st.x = e.x; st.y = e.y; st.t = now; } + var moving = (now - st.t) < 360; + var resolved = (root.RK && RK.resolveAppearance) ? RK.resolveAppearance(e, 'iso') : null; + var ch = (resolved && resolved.character) || (this.assets.character || {}); + if (!moving || !ch.run || ch.run.length === 0) return ch.idle; + this._animating = true; + return ch.run[Math.floor(now / 80) % ch.run.length]; + }; + + IsoRenderer.prototype.render = function (scene) { + if (!scene) return; + this.lastScene = scene; + this._animating = false; + var margin = 28; + var originX = margin + scene.rows * this.tileW / 2 + this.tileW; + var originY = margin + this.imageH; + this.canvas.width = Math.ceil((scene.cols + scene.rows) * this.tileW / 2 + margin * 2 + this.tileW * 2); + this.canvas.height = Math.ceil((scene.cols + scene.rows) * this.tileH / 2 + this.imageH + margin * 2); + + var ctx = this.ctx; + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + ctx.fillStyle = scene.background || '#0a0c0f'; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + ctx.imageSmoothingEnabled = true; + + var items = [], x, y, kind, p; + for (y = 0; y < scene.rows; y++) { + for (x = 0; x < scene.cols; x++) { + kind = scene.grid[y][x]; + p = this._project(x, y, originX, originY); + items.push({ type: 'tile', kind: kind, x: x, y: y, sx: p.x, sy: p.y, depth: x + y }); + if (kind === 'bar' || kind === 'table') { + items.push({ type: 'prop', kind: kind, x: x, y: y, sx: p.x, sy: p.y, depth: x + y + 0.25 }); + } + } + } + for (var i = 0; i < scene.entities.length; i++) { + var e = scene.entities[i]; + p = this._project(e.x, e.y, originX, originY); + items.push({ type: 'entity', e: e, sx: p.x, sy: p.y, depth: e.x + e.y + 0.55 }); + } + items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; }); + + var now = Date.now(); + for (i = 0; i < items.length; i++) { + var it = items[i]; + if (it.type === 'tile') { + var tileKind = (it.kind === 'bar' || it.kind === 'table') ? 'floor' : it.kind; + var rec = this._load(this._tileUrl(tileKind)); + if (rec && rec.ready) this._drawImage(rec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH); + } else if (it.type === 'prop') { + rec = this._load(this._tileUrl(it.kind)); + if (rec && rec.ready) this._drawImage(rec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH); + } else if (it.type === 'entity') { + var frame = this._charFrame(it.e, now); + rec = this._load(frame); + if (rec && rec.ready) { + var ch = this.assets.character || {}; + this._drawImage(rec.img, it.sx, it.sy + this.tileH * 0.95, ch.imageW || 74, ch.imageH || 148); + } else { + ctx.beginPath(); + ctx.arc(it.sx, it.sy, 8, 0, Math.PI * 2); + ctx.fillStyle = it.e.color || '#d7dbe0'; + ctx.fill(); + } + if (it.e.you) { + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(it.sx, it.sy + this.tileH * 0.7, 20, 8, 0, 0, Math.PI * 2); + ctx.stroke(); + } + if (it.e.label) { + ctx.fillStyle = '#d7dbe0'; + ctx.font = '11px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(it.e.label, it.sx, it.sy - 62); + } + } + } + if (this._animating) this._invalidate(); + }; + + IsoRenderer.prototype.destroy = function () { + if (this._raf) cancelAnimationFrame(this._raf); + if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas); + this.canvas = null; + this.ctx = null; + this.cache = {}; + this.last = {}; + this.lastScene = null; + }; + + root.RK = root.RK || {}; + root.RK.IsoRenderer = IsoRenderer; +})(window); diff --git a/html/js/render/renderModes.js b/html/js/render/renderModes.js new file mode 100644 index 0000000..929bbde --- /dev/null +++ b/html/js/render/renderModes.js @@ -0,0 +1,131 @@ +// Render mode registry, factory, and entitlements for the render kit (RK). +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + // Render tiers. `premium` marks modes intended to be unlocked with credits — a cosmetic + // entitlement gated by Operator Policy, the same pattern as avatar unlocks. NOTE: rendering + // is client-side, so this is a soft/honour gate for cosmetics, not a hard security boundary. + RK.RENDER_MODES = [ + { id: 'tiles', label: 'Tiled', premium: false }, + { id: 'ascii', label: 'ASCII', premium: true }, + { id: 'fancy', label: 'Fancy', premium: true, pack: 'generated-skins' }, + { id: 'iso', label: 'Iso', premium: true, pack: 'iso-dungeon' }, + { id: '3d', label: '3D', premium: true, pack: 'kenney-3d-characters' } + // Planned: 'fancy-ascii' — shader-lit, animated glyphs. + ]; + + RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {} }; + + RK.modeMeta = function (id) { + for (var i = 0; i < RK.RENDER_MODES.length; i++) { + if (RK.RENDER_MODES[i].id === id) return RK.RENDER_MODES[i]; + } + return null; + }; + + RK.canUseMode = function (id) { + var m = RK.modeMeta(id); + if (!m) return false; + if (m.pack && RK.canUsePack) return RK.canUsePack(m.pack); + return !m.premium || !!RK.entitlements.premium; + }; + + RK.createRenderer = function (mode, host, opts) { + if (mode === 'ascii' && RK.AsciiRenderer) return new RK.AsciiRenderer(host, opts); + if (mode === 'iso' && RK.IsoRenderer) return new RK.IsoRenderer(host, opts); + if (mode === '3d' && RK.ThreeRenderer && RK.THREE) return new RK.ThreeRenderer(host, opts); + if (mode === 'fancy') { + try { + if (!RK.FancyRenderer) throw new Error('fancy renderer not loaded'); + return new RK.FancyRenderer(host, opts); + } catch (e) { + if (root.console) console.warn('Fancy renderer unavailable; using tiled:', e.message); + return new RK.TileRenderer(host, opts); + } + } + return new RK.TileRenderer(host, opts); // default / fallback + }; + + // Lazy-load PixiJS (+ @pixi/unsafe-eval for the strict CSP) only when Fancy is first needed, + // so ASCII/Tiled users never download the WebGL library. + var pixiLoading = false, pixiCbs = []; + RK.pixiReady = function () { return typeof PIXI !== 'undefined'; }; + RK.ensurePixi = function (cb) { + if (RK.pixiReady()) { cb(true); return; } + pixiCbs.push(cb); + if (pixiLoading) return; + pixiLoading = true; + var inject = function (src, onload, onerror) { + var el = document.createElement('script'); + el.src = src; el.onload = onload; el.onerror = onerror; + document.head.appendChild(el); + }; + var done = function (ok) { var cbs = pixiCbs; pixiCbs = []; cbs.forEach(function (f) { f(ok); }); }; + inject('https://cdn.jsdelivr.net/npm/pixi.js@7.4.2/dist/pixi.min.js', function () { + // @pixi/unsafe-eval patches the shader compiler on load (needed under the CSP). + inject('https://cdn.jsdelivr.net/npm/@pixi/unsafe-eval@7.4.2/dist/unsafe-eval.min.js', + function () { done(true); }, function () { done(false); }); + }, function () { done(false); }); + }; + + var threeLoading = false, threeCbs = []; + RK.threeReady = function () { return !!(RK.THREE && RK.THREE.THREE); }; + RK.ensureThree = function (cb) { + if (RK.threeReady()) { cb(true); return; } + threeCbs.push(cb); + if (threeLoading) return; + threeLoading = true; + Promise.all([ + import('https://cdn.jsdelivr.net/npm/three@0.160.0/+esm'), + import('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js/+esm') + ]).then(function (mods) { + RK.THREE = { THREE: mods[0], GLTFLoader: mods[1].GLTFLoader }; + var cbs = threeCbs; threeCbs = []; + cbs.forEach(function (f) { f(true); }); + }).catch(function (e) { + if (root.console) console.warn('Three renderer unavailable:', e && e.message); + var cbs = threeCbs; threeCbs = []; + cbs.forEach(function (f) { f(false); }); + }); + }; + + // Create a renderer, loading Pixi on demand for Fancy; falls back to Tiled if Pixi fails. + RK.createRendererAsync = function (mode, host, opts, cb) { + if (mode === 'fancy') { + RK.ensurePixi(function (ok) { + if (!ok) { cb(new RK.TileRenderer(host, opts)); return; } + try { cb(new RK.FancyRenderer(host, opts)); } + catch (e) { + if (root.console) console.warn('Fancy renderer failed; using tiled:', e.message); + cb(new RK.TileRenderer(host, opts)); + } + }); + return; + } + if (mode === '3d') { + RK.ensureThree(function (ok) { + if (!ok) { cb(new RK.TileRenderer(host, opts)); return; } + try { cb(new RK.ThreeRenderer(host, opts)); } + catch (e) { + if (root.console) console.warn('3D renderer failed; using tiled:', e.message); + cb(new RK.TileRenderer(host, opts)); + } + }); + return; + } + cb(RK.createRenderer(mode, host, opts)); + }; + + RK.loadMode = function (def) { + try { + var m = localStorage.getItem('rk_mode'); + if (RK.modeMeta(m) && RK.canUseMode(m)) return m; + } catch (_) { /* ignore */ } + return def || 'tiles'; + }; + + RK.saveMode = function (mode) { + try { localStorage.setItem('rk_mode', mode); } catch (_) { /* ignore */ } + }; +})(window); diff --git a/html/js/render/sceneModel.js b/html/js/render/sceneModel.js new file mode 100644 index 0000000..a08bd48 --- /dev/null +++ b/html/js/render/sceneModel.js @@ -0,0 +1,77 @@ +// Shared scene model + adapters for the render kit (RK). +// +// A Scene is a renderer-agnostic description of a grid world + entities. Any renderer +// (ASCII / tiled / fancy) draws the same Scene, and any surface (tavern / main game) can +// produce one via an adapter. This file is DOM-free so the adapters stay unit-testable. +(function (root) { + 'use strict'; + + // Tile kinds and how each renderer treats them (glyph for ASCII, colour for tiled/fancy). + var TAVERN_LEGEND = { + wall: { char: '#', color: '#20262e', solid: true }, + floor: { char: '·', color: '#0c1410', solid: false }, + bar: { char: '=', color: '#5a4632', solid: true }, + table: { char: 'o', color: '#4a3a28', solid: true } + }; + + // Layout character -> tile kind. + var TAVERN_TILE_OF = { '#': 'wall', '.': 'floor', '@': 'floor', '=': 'bar', 'o': 'table' }; + + var AVATAR_COLORS = { 'default': '#9aa4b2', green: '#3fb950', amber: '#d29922', red: '#f85149' }; + + // Build a Scene from a tavern room state ({ layout, cols, rows, occupants }). + function sceneFromTavern(state, youId) { + state = state || {}; + var layout = state.layout || []; + var rows = state.rows || layout.length; + var cols = state.cols || (layout[0] ? layout[0].length : 0); + + var grid = []; + for (var y = 0; y < rows; y++) { + var srcRow = layout[y] || ''; + var row = []; + for (var x = 0; x < cols; x++) { + row.push(TAVERN_TILE_OF[srcRow[x]] || 'wall'); + } + grid.push(row); + } + + var occ = state.occupants || []; + var entities = occ.map(function (o) { + var appearance = o.appearance || (o.avatar ? { avatar: o.avatar } : null); + var avatar = (appearance && appearance.avatar) || o.avatar; + return { + id: o.id, + x: o.x, + y: o.y, + kind: 'avatar', + avatar: avatar, // preserved so renderers can detect premium skins + appearance: appearance, + color: AVATAR_COLORS[avatar] || AVATAR_COLORS.default, + char: '@', + facing: o.facing || 'down', + label: o.name || null, + you: !!youId && o.id === youId + }; + }); + + return { + cols: cols, + rows: rows, + grid: grid, + legend: TAVERN_LEGEND, + entities: entities, + background: '#0a0c0f' + }; + } + + var api = { + sceneFromTavern: sceneFromTavern, + TAVERN_LEGEND: TAVERN_LEGEND, + AVATAR_COLORS: AVATAR_COLORS + }; + + root.RK = root.RK || {}; + root.RK.scene = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(typeof window !== 'undefined' ? window : this); diff --git a/html/js/render/skins.js b/html/js/render/skins.js new file mode 100644 index 0000000..7611fe4 --- /dev/null +++ b/html/js/render/skins.js @@ -0,0 +1,118 @@ +// Character skins for the render kit. A skin is an animated sprite sheet (rows = facing +// directions, cols = animation frames) produced by the sprite pipeline +// (~/src/docs/animated-sprite-pipeline.md). Sheets are HEAVY and PREMIUM, so they are: +// - lazy-loaded (only fetched when an entity actually uses the skin, in a sprite-capable mode), +// - gated by entitlement (RK.entitlements.premium), +// - delivered as WebP. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + // Skin registry. `sheet` is hosted under assets/generated (gitignored / CDN-delivered). + RK.SKINS = { + 'monero-knight': { + id: 'monero-knight', label: 'Monero Knight', premium: true, pack: 'generated-skins', + sheet: 'assets/generated/monero_knight_walk.webp', + frameW: 190, frameH: 233, cols: 8, rows: 4, fps: 10, + dirRows: { down: 0, left: 1, right: 2, up: 3 }, + scale: 2.0, // sprite height ≈ scale × tile cell + moveWindowMs: 350 // consider "moving" (animate) if position changed this recently + }, + 'wownero-rogue': { + id: 'wownero-rogue', label: 'Wownero Rogue', premium: true, pack: 'generated-skins', + sheet: 'assets/generated/wownero_walk.webp', + frameW: 169, frameH: 223, cols: 8, rows: 4, fps: 10, + dirRows: { down: 0, left: 1, right: 2, up: 3 }, + scale: 2.0, moveWindowMs: 350 + }, + 'cypher-operative': { + id: 'cypher-operative', label: 'Cypher Operative', premium: true, pack: 'generated-skins', + sheet: 'assets/generated/operative_walk.webp', + frameW: 160, frameH: 225, cols: 8, rows: 4, fps: 10, + dirRows: { down: 0, left: 1, right: 2, up: 3 }, + scale: 2.0, moveWindowMs: 350 + } + }; + + RK.isSkin = function (id) { return !!(id && RK.SKINS[id]); }; + + // --- Lazy sheet loading ------------------------------------------------------------------ + var sheets = {}; // id -> { ready, img, base, texCache, cbs } + RK.loadSkin = function (id) { + var s = RK.SKINS[id]; + if (!s) return null; + if (sheets[id]) return sheets[id]; + var rec = sheets[id] = { ready: false, cbs: [] }; + var img = new Image(); + img.onload = function () { + rec.ready = true; rec.img = img; + rec.cbs.forEach(function (cb) { try { cb(rec); } catch (_) {} }); + rec.cbs = []; + }; + img.onerror = function () { if (root.console) console.warn('skin sheet failed:', s.sheet); }; + img.src = s.sheet; + return rec; + }; + RK.skinSheet = function (id) { return (sheets[id] && sheets[id].ready) ? sheets[id] : null; }; + + // --- Movement-aware frame selection ------------------------------------------------------ + var last = {}; // entity id -> { x, y, t } + RK.skinFrame = function (skin, e, nowMs) { + var st = last[e.id] || (last[e.id] = { x: e.x, y: e.y, t: 0 }); + if (e.x !== st.x || e.y !== st.y) { st.x = e.x; st.y = e.y; st.t = nowMs; } + var moving = (nowMs - st.t) < (skin.moveWindowMs || 350); + var row = (skin.dirRows && skin.dirRows[e.facing] != null) ? skin.dirRows[e.facing] : 0; + var col = moving ? Math.floor(nowMs / 1000 * skin.fps) % skin.cols : 0; // idle -> frame 0 + return { row: row, col: col }; + }; + + // --- Canvas draw (TileRenderer). Returns false (and kicks a lazy load) if not ready. ------ + RK.drawSkinCanvas = function (ctx, e, cell, nowMs) { + var rec = RK.skinSheet(e.avatar); + if (!rec) { RK.loadSkin(e.avatar); return false; } + var s = RK.SKINS[e.avatar]; + var fr = RK.skinFrame(s, e, nowMs); + var dh = cell * (s.scale || 1.7); + var dw = dh * (s.frameW / s.frameH); + var cx = e.x * cell + cell / 2; + var dx = cx - dw / 2; + var dy = (e.y * cell + cell) - dh; // feet at tile bottom + ctx.imageSmoothingEnabled = true; + // Warm backlight so a dark character separates from the dark background. + var midY = dy + dh * 0.45; + var halo = ctx.createRadialGradient(cx, midY, 0, cx, midY, dw * 0.8); + halo.addColorStop(0, 'rgba(255,226,180,0.30)'); + halo.addColorStop(1, 'rgba(255,226,180,0)'); + ctx.fillStyle = halo; + ctx.beginPath(); ctx.arc(cx, midY, dw * 0.8, 0, Math.PI * 2); ctx.fill(); + if (e.you) { + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(cx, e.y * cell + cell - 2, dw * 0.3, cell * 0.16, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.drawImage(rec.img, fr.col * s.frameW, fr.row * s.frameH, s.frameW, s.frameH, dx, dy, dw, dh); + if (e.label) { + ctx.fillStyle = '#d7dbe0'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; + ctx.fillText(e.label, cx, dy - 2); + } + return true; + }; + + // --- Pixi texture for a skin frame (FancyRenderer), cached. ------------------------------ + RK.skinTexture = function (id, row, col) { + var rec = RK.skinSheet(id); + if (!rec || typeof PIXI === 'undefined') return null; + var s = RK.SKINS[id]; + if (!rec.base) { + rec.base = PIXI.BaseTexture.from(rec.img); + rec.base.scaleMode = PIXI.SCALE_MODES.LINEAR; + rec.texCache = {}; + } + var key = row + ',' + col; + if (!rec.texCache[key]) { + rec.texCache[key] = new PIXI.Texture(rec.base, new PIXI.Rectangle(col * s.frameW, row * s.frameH, s.frameW, s.frameH)); + } + return rec.texCache[key]; + }; +})(window); diff --git a/html/js/render/threeRenderer.js b/html/js/render/threeRenderer.js new file mode 100644 index 0000000..514670d --- /dev/null +++ b/html/js/render/threeRenderer.js @@ -0,0 +1,210 @@ +// ThreeRenderer — lightweight 3D projection for the shared Scene. It uses generated GLB avatars +// when available and falls back to low-poly pieces so the mode remains usable during asset work. +(function (root) { + 'use strict'; + + function colorNum(hex) { return parseInt(String(hex || '#9aa4b2').replace('#', ''), 16) || 0x9aa4b2; } + + function ThreeRenderer(host, opts) { + opts = opts || {}; + if (!root.RK || !RK.THREE || !RK.THREE.THREE) throw new Error('THREE not loaded'); + this.name = '3d'; + this.host = host; + this.THREE = RK.THREE.THREE; + this.GLTFLoader = RK.THREE.GLTFLoader; + this.models = {}; + this.mixers = []; + this.entities = {}; + this.last = {}; + this._sizeKey = null; + this._init(); + } + + ThreeRenderer.prototype._init = function () { + var T = this.THREE; + this.scene = new T.Scene(); + this.scene.background = new T.Color(0x0a0c0f); + this.camera = new T.OrthographicCamera(-10, 10, 10, -10, 0.1, 100); + this.camera.position.set(8, 9, 8); + this.camera.lookAt(0, 0, 0); + this.renderer = new T.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true }); + this.renderer.domElement.className = 'rk-canvas'; + this.renderer.setPixelRatio(Math.min(2, root.devicePixelRatio || 1)); + this.host.appendChild(this.renderer.domElement); + + var hemi = new T.HemisphereLight(0xfff3d2, 0x283044, 2.1); + this.scene.add(hemi); + var key = new T.DirectionalLight(0xffffff, 1.6); + key.position.set(6, 10, 5); + this.scene.add(key); + this.world = new T.Group(); + this.scene.add(this.world); + this.clock = new T.Clock(); + var self = this; + this._raf = requestAnimationFrame(function tick() { self._animate(); self._raf = requestAnimationFrame(tick); }); + }; + + ThreeRenderer.prototype._resize = function (scene) { + var w = Math.max(420, scene.cols * 32); + var h = Math.max(360, scene.rows * 28 + 120); + this.renderer.setSize(w, h, false); + var aspect = w / h, span = Math.max(scene.cols, scene.rows) * 0.78 + 2.5; + this.camera.left = -span * aspect; + this.camera.right = span * aspect; + this.camera.top = span; + this.camera.bottom = -span; + this.camera.updateProjectionMatrix(); + }; + + ThreeRenderer.prototype._mat = function (hex) { + var T = this.THREE; + return new T.MeshStandardMaterial({ color: colorNum(hex), roughness: 0.82, metalness: 0.02 }); + }; + + ThreeRenderer.prototype._buildTiles = function (scene) { + var T = this.THREE; + this.world.clear(); + this.tileLayer = new T.Group(); + this.entityLayer = new T.Group(); + this.world.add(this.tileLayer, this.entityLayer); + var cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2; + for (var y = 0; y < scene.rows; y++) { + for (var x = 0; x < scene.cols; x++) { + var kind = scene.grid[y][x]; + var def = scene.legend[kind] || {}; + var h = kind === 'wall' ? 0.9 : (kind === 'bar' ? 0.42 : (kind === 'table' ? 0.28 : 0.08)); + var geom = new T.BoxGeometry(kind === 'wall' ? 1 : 0.96, h, kind === 'wall' ? 1 : 0.96); + var mesh = new T.Mesh(geom, this._mat(kind === 'floor' ? '#4f4a3e' : (def.color || '#333333'))); + mesh.position.set(x - cx, h / 2 - 0.05, y - cz); + this.tileLayer.add(mesh); + } + } + this.world.position.set(0, 0, 0); + }; + + ThreeRenderer.prototype._fallbackAvatar = function (e) { + var T = this.THREE; + var g = new T.Group(); + var body = new T.Mesh(new T.CapsuleGeometry(0.24, 0.56, 4, 8), this._mat(e.color || '#9aa4b2')); + body.position.y = 0.52; + g.add(body); + var face = new T.Mesh(new T.BoxGeometry(0.11, 0.07, 0.035), this._mat('#0a0c0f')); + face.position.set(0, 0.7, 0.23); + g.add(face); + return g; + }; + + ThreeRenderer.prototype._loadModel = function (resolved, cb) { + if (!resolved || !resolved.model || !resolved.model.url || !this.GLTFLoader) { cb(null); return; } + var url = resolved.model.url; + if (this.models[url]) { + if (this.models[url].ready) cb(this.models[url]); + else this.models[url].cbs.push(cb); + return; + } + var rec = this.models[url] = { ready: false, cbs: [cb] }; + var loader = new this.GLTFLoader(); + loader.load(url, function (gltf) { + rec.ready = true; + rec.gltf = gltf; + var cbs = rec.cbs; rec.cbs = []; + cbs.forEach(function (fn) { fn(rec); }); + }, null, function () { + rec.ready = true; + rec.error = true; + var cbs = rec.cbs; rec.cbs = []; + cbs.forEach(function (fn) { fn(null); }); + }); + }; + + ThreeRenderer.prototype._makeEntity = function (e) { + var T = this.THREE, self = this; + var shell = new T.Group(); + shell._body = this._fallbackAvatar(e); + shell.add(shell._body); + var resolved = (root.RK && RK.resolveAppearance) ? RK.resolveAppearance(e, '3d') : null; + if (root.RK && RK.canUsePack && resolved && RK.canUsePack(resolved.pack)) { + this._loadModel(resolved, function (rec) { + if (!rec || !rec.gltf || !shell.parent) return; + shell.remove(shell._body); + var model = rec.gltf.scene.clone(true); + model.scale.setScalar(0.55); + model.rotation.y = Math.PI; + shell.add(model); + shell._body = model; + if (rec.gltf.animations && rec.gltf.animations.length) { + var mixer = new T.AnimationMixer(model); + shell._mixer = mixer; + shell._actions = {}; + rec.gltf.animations.forEach(function (clip) { + shell._actions[String(clip.name).toLowerCase()] = mixer.clipAction(clip); + }); + var idle = shell._actions.idle || shell._actions[Object.keys(shell._actions)[0]]; + if (idle) idle.play(); + self.mixers.push(mixer); + } + }); + } + return shell; + }; + + ThreeRenderer.prototype.render = function (scene) { + if (!scene || !this.renderer) return; + this._resize(scene); + var key = scene.cols + 'x' + scene.rows + ':' + scene.grid.map(function (r) { return r.join(''); }).join('/'); + if (key !== this._sizeKey) { + this._buildTiles(scene); + this._sizeKey = key; + } + var seen = {}, cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2; + for (var i = 0; i < scene.entities.length; i++) { + var e = scene.entities[i], id = e.id; + seen[id] = true; + var ent = this.entities[id]; + if (!ent) { + ent = this.entities[id] = { obj: this._makeEntity(e), x: e.x, y: e.y }; + this.entityLayer.add(ent.obj); + } + ent.e = e; + ent.tx = e.x - cx; + ent.tz = e.y - cz; + } + for (id in this.entities) { + if (!seen[id]) { + this.entityLayer.remove(this.entities[id].obj); + delete this.entities[id]; + } + } + }; + + ThreeRenderer.prototype._animate = function () { + if (!this.renderer) return; + var dt = this.clock.getDelta(); + for (var i = 0; i < this.mixers.length; i++) this.mixers[i].update(dt); + for (var id in this.entities) { + var ent = this.entities[id], o = ent.obj; + o.position.x += ((ent.tx || 0) - o.position.x) * 0.18; + o.position.z += ((ent.tz || 0) - o.position.z) * 0.18; + if (ent.e && ent.e.facing) { + var r = ent.e.facing === 'up' ? Math.PI : ent.e.facing === 'left' ? -Math.PI / 2 : ent.e.facing === 'right' ? Math.PI / 2 : 0; + o.rotation.y += (r - o.rotation.y) * 0.18; + } + } + this.renderer.render(this.scene, this.camera); + }; + + ThreeRenderer.prototype.destroy = function () { + if (this._raf) cancelAnimationFrame(this._raf); + if (this.renderer) { + if (this.renderer.domElement && this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); + this.renderer.dispose(); + } + this.renderer = null; + this.entities = {}; + this.models = {}; + this.mixers = []; + }; + + root.RK = root.RK || {}; + root.RK.ThreeRenderer = ThreeRenderer; +})(window); diff --git a/html/js/render/tilePicker.js b/html/js/render/tilePicker.js new file mode 100644 index 0000000..8629e49 --- /dev/null +++ b/html/js/render/tilePicker.js @@ -0,0 +1,82 @@ +// Tile-picker — a dev overlay to map a theme's tileset cells to game tile-kinds by CLICKING. +// Pick a kind (floor/wall/bar/table), click the matching cell in the sheet; it's saved to +// localStorage and the tavern re-renders live. Works for any pack, so no coordinate-guessing. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + RK.openTilePicker = function (onChange) { + var themeId = RK.activeThemeId; + var atlas = RK.themeAtlas(themeId); + if (!atlas) return; + atlas.onReady(function () { build(themeId, atlas, onChange); }); + }; + + function build(themeId, atlas, onChange) { + var SCALE = 2; + var stride = atlas.tile + atlas.spacing; + + var wrap = document.createElement('div'); + wrap.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);z-index:9999;overflow:auto;padding:16px;font-family:ui-monospace,monospace;color:#d7dbe0;'; + + var bar = document.createElement('div'); + bar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap;'; + var active = RK.TILE_KINDS[0]; + var status = document.createElement('span'); + status.style.cssText = 'font-size:13px;color:#9cf;'; + + RK.TILE_KINDS.forEach(function (k) { + var b = document.createElement('button'); + b.textContent = k; b.dataset.kind = k; + b.style.cssText = 'font:inherit;padding:6px 10px;border:1px solid #2a313a;border-radius:4px;background:#0a0c0f;color:#d7dbe0;cursor:pointer;'; + b.onclick = function () { active = k; refresh(); status.textContent = 'Click the tile for: ' + k; }; + bar.appendChild(b); + }); + var done = document.createElement('button'); + done.textContent = 'Done'; + done.style.cssText = 'font:inherit;padding:6px 12px;border:0;border-radius:4px;background:#3fb950;color:#08130a;cursor:pointer;font-weight:600;margin-left:auto;'; + done.onclick = function () { document.body.removeChild(wrap); }; + bar.appendChild(status); bar.appendChild(done); + function refresh() { + Array.prototype.forEach.call(bar.querySelectorAll('button[data-kind]'), function (b) { + b.style.outline = b.dataset.kind === active ? '2px solid #3fb950' : 'none'; + }); + } + wrap.appendChild(bar); + + var cv = document.createElement('canvas'); + cv.width = atlas.img.width * SCALE; cv.height = atlas.img.height * SCALE; + cv.style.cssText = 'image-rendering:pixelated;border:1px solid #2a313a;background:#111;cursor:crosshair;'; + var ctx = cv.getContext('2d'); + + function draw() { + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, cv.width, cv.height); + ctx.drawImage(atlas.img, 0, 0, cv.width, cv.height); + ctx.strokeStyle = 'rgba(63,185,80,0.22)'; ctx.lineWidth = 1; + for (var c = 0; c <= atlas.cols; c++) { ctx.beginPath(); ctx.moveTo(c * stride * SCALE, 0); ctx.lineTo(c * stride * SCALE, cv.height); ctx.stroke(); } + for (var r = 0; r <= atlas.rows; r++) { ctx.beginPath(); ctx.moveTo(0, r * stride * SCALE); ctx.lineTo(cv.width, r * stride * SCALE); ctx.stroke(); } + var map = RK.tileMap(themeId); + RK.TILE_KINDS.forEach(function (k) { + var co = map[k]; if (!co) return; + ctx.strokeStyle = '#ffb800'; ctx.lineWidth = 2; + ctx.strokeRect(co[0] * stride * SCALE, co[1] * stride * SCALE, atlas.tile * SCALE, atlas.tile * SCALE); + ctx.fillStyle = '#ffb800'; ctx.font = 'bold 11px monospace'; + ctx.fillText(k[0].toUpperCase(), co[0] * stride * SCALE + 2, co[1] * stride * SCALE + 11); + }); + } + cv.onclick = function (e) { + var rect = cv.getBoundingClientRect(); + var col = Math.floor(((e.clientX - rect.left) / SCALE) / stride); + var row = Math.floor(((e.clientY - rect.top) / SCALE) / stride); + if (col < 0 || row < 0 || col >= atlas.cols || row >= atlas.rows) return; + RK.setTile(themeId, active, col, row); + draw(); + if (onChange) onChange(); + }; + + wrap.appendChild(cv); + document.body.appendChild(wrap); + refresh(); status.textContent = 'Click the tile for: ' + active; draw(); + } +})(window); diff --git a/html/js/render/tileRenderer.js b/html/js/render/tileRenderer.js new file mode 100644 index 0000000..cec61c1 --- /dev/null +++ b/html/js/render/tileRenderer.js @@ -0,0 +1,102 @@ +// TileRenderer — draws a Scene as coloured tiles + entity sprites on a 2D canvas. +// The default tier. Uses programmatic tiles for now; a real tileset/atlas can slot in here. +(function (root) { + 'use strict'; + + function facingDelta(f) { + if (f === 'up') return { x: 0, y: -1 }; + if (f === 'left') return { x: -1, y: 0 }; + if (f === 'right') return { x: 1, y: 0 }; + return { x: 0, y: 1 }; + } + + function TileRenderer(host, opts) { + opts = opts || {}; + this.name = 'tiles'; + this.host = host; + this.cell = opts.cell || 24; + this.canvas = document.createElement('canvas'); + this.canvas.className = 'rk-canvas'; + this.ctx = this.canvas.getContext('2d'); + host.appendChild(this.canvas); + } + + TileRenderer.prototype.render = function (scene) { + if (!scene) return; + var cell = this.cell, ctx = this.ctx; + this.canvas.width = scene.cols * cell; + this.canvas.height = scene.rows * cell; + + ctx.fillStyle = scene.background || '#000'; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + if (window.RK && RK.roomReady && RK.roomReady()) { + // A designed room (imported .tmx) is loaded — draw its layered tiles. + RK.drawRoomCanvas(ctx, cell); + } else { + // Theme atlas tile if mapped, else the programmatic coloured tile. + var atlas = (window.RK && RK.themeAtlas) ? RK.themeAtlas() : null; + var tmap = (window.RK && RK.tileMap) ? RK.tileMap() : {}; + var useAtlas = atlas && atlas.ready; + for (var y = 0; y < scene.rows; y++) { + for (var x = 0; x < scene.cols; x++) { + var kind = scene.grid[y][x]; + var px = x * cell, py = y * cell; + var coord = useAtlas ? tmap[kind] : null; + if (coord) { atlas.draw(ctx, coord[0], coord[1], px, py, cell); continue; } + var def = scene.legend[kind] || { color: '#333' }; + ctx.fillStyle = def.color; + ctx.fillRect(px, py, cell, cell); + ctx.strokeStyle = 'rgba(0,0,0,0.25)'; + ctx.lineWidth = 1; + ctx.strokeRect(px + 0.5, py + 0.5, cell - 1, cell - 1); + if (def.solid) { + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + ctx.fillRect(px, py, cell, Math.max(1, cell * 0.14)); + } + } + } + } + + var now = Date.now(); + for (var i = 0; i < scene.entities.length; i++) { + var e = scene.entities[i]; + // Roguelike character sprite, or premium animated skin; else the fallback circle. + if (window.RK && RK.isChar && RK.isChar(e.avatar) && RK.drawCharCanvas(ctx, e, cell, now)) continue; + if (window.RK && RK.isSkin && RK.isSkin(e.avatar) && RK.drawSkinCanvas(ctx, e, cell, now)) continue; + var cx = e.x * cell + cell / 2, cy = e.y * cell + cell / 2, r = cell * 0.36; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = e.color || '#fff'; + ctx.fill(); + // facing pip + var d = facingDelta(e.facing); + ctx.beginPath(); + ctx.arc(cx + d.x * r * 0.6, cy + d.y * r * 0.6, cell * 0.08, 0, Math.PI * 2); + ctx.fillStyle = '#0a0c0f'; + ctx.fill(); + if (e.you) { + ctx.lineWidth = 2; + ctx.strokeStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + } + if (e.label) { + ctx.fillStyle = '#d7dbe0'; + ctx.font = '11px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(e.label, cx, cy - cell * 0.5); + } + } + }; + + TileRenderer.prototype.destroy = function () { + if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas); + this.canvas = null; + this.ctx = null; + }; + + root.RK = root.RK || {}; + root.RK.TileRenderer = TileRenderer; +})(window); diff --git a/html/js/ui/paymentUI.js b/html/js/ui/paymentUI.js index 1d96d69..5f27295 100644 --- a/html/js/ui/paymentUI.js +++ b/html/js/ui/paymentUI.js @@ -101,6 +101,43 @@ const PaymentUI = { return (atomicAmount / divisor).toFixed(decimals === 12 ? 4 : 2); }, + escapeHtml: function(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function(ch) { + return ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[ch]; + }); + }, + + grantLabel: function(packId) { + const pack = window.RK && window.RK.PACKS ? window.RK.PACKS[packId] : null; + const labels = { + 'generated-skins': 'Premium skins', + 'iso-dungeon': 'Isometric dungeon', + 'kenney-3d-characters': '3D characters', + 'roguelike-interior': 'Roguelike interior' + }; + return (pack && pack.label) || labels[packId] || packId; + }, + + grantSummaryHtml: function(grants, options = {}) { + if (!grants) return ''; + const parts = []; + if (options.includeCredits && Number(grants.credits || 0) > 0) { + parts.push(`${Number(grants.credits)} credits`); + } + (grants.packs || []).forEach(packId => parts.push(this.grantLabel(packId))); + if (grants.premiumLevel) { + parts.push(`${grants.premiumLevel} tier`); + } + if (parts.length === 0) return ''; + return `
Includes: ${this.escapeHtml(parts.join(' • '))}
`; + }, + render: function() { if (!this.config) return; @@ -109,6 +146,7 @@ const PaymentUI = { const currency = this.config.currencyLabel || this.config.cryptoType || 'WOW'; const hasCredits = this.userCredits >= (this.config.creditsPerGame || 1); const creditPackages = this.config.creditPackages || []; + const cosmeticProducts = this.config.cosmeticProducts || []; const $container = $('.game-modes'); $container.empty(); @@ -228,6 +266,7 @@ const PaymentUI = { const bonusText = pkg.bonus > 0 ? ` +${pkg.bonus} bonus` : ''; + const grantSummary = this.grantSummaryHtml(pkg.grants, { indent: 22 }); const checked = index === 0 ? 'checked' : ''; if (index === 0) this.selectedPackageId = pkg.id; @@ -241,6 +280,7 @@ const PaymentUI = {
${perGamePrice} ${currency}/game for ${totalCredits} games
+ ${grantSummary} `; }); @@ -262,6 +302,39 @@ const PaymentUI = { `); } + + // === SECTION 4: Standalone cosmetic/render products === + if (this.config.paymentsEnabled && cosmeticProducts.length > 0) { + let productsHtml = ''; + cosmeticProducts.forEach(product => { + const priceFormatted = product.priceFormatted || this.formatPrice(product.price); + const label = this.escapeHtml(product.label || product.id); + const grantSummary = this.grantSummaryHtml(product.grants, { includeCredits: true }); + productsHtml += ` +
+
+ ${label} + ${priceFormatted} ${currency} +
+ ${grantSummary} + +
+ `; + }); + + $container.append(` +
+
+ Premium Packs + Cosmetics and render modes +
+ ${productsHtml} +
+ `); + } // === Credits balance display (if has some but not enough) === if (this.config.creditsModeEnabled && this.userCredits > 0 && !hasCredits) { @@ -314,11 +387,17 @@ const PaymentUI = { let paymentType = 'single_game'; if (action === 'buy_credits') { paymentType = 'credits_package'; + } else if (action === 'buy_product') { + paymentType = 'cosmetic_pack'; } const requestData = { type: paymentType }; if (packageId) { - requestData.packageId = packageId; + if (paymentType === 'cosmetic_pack') { + requestData.productId = packageId; + } else { + requestData.packageId = packageId; + } } if (window.socket) { @@ -339,10 +418,13 @@ const PaymentUI = { $('.game-modes').hide(); const isCreditsPurchase = data.paymentType === 'credits_package'; + const isProductPurchase = data.paymentType === 'cosmetic_pack'; let headerText = '💳 Payment Required'; if (isCreditsPurchase && data.package) { const bonus = data.package.bonus > 0 ? ` +${data.package.bonus} bonus` : ''; headerText = `🎫 Buy ${data.package.credits}${bonus} Credits`; + } else if (isProductPurchase && data.package) { + headerText = `Buy ${data.package.label || data.package.id || 'Pack'}`; } $('.payment-header strong').text(headerText); @@ -354,9 +436,9 @@ const PaymentUI = { if (data.reused) { $('#payment-status').html('♻️ Using existing pending payment'); } else { - const statusText = isCreditsPurchase + const statusText = isCreditsPurchase ? 'Send payment to receive credits...' - : 'Waiting for payment...'; + : (isProductPurchase ? 'Send payment to unlock this pack...' : 'Waiting for payment...'); $('#payment-status').text(statusText); } }, diff --git a/html/tavern.html b/html/tavern.html new file mode 100644 index 0000000..ca9ab22 --- /dev/null +++ b/html/tavern.html @@ -0,0 +1,301 @@ + + + + + + Tavern - Wownerogue + + + + + + + + + + + + + + + + + + +
+

Tavern — early preview

+ +
+ + + + + +
+ + + +

+

Move with arrow keys or WASD. ★ modes are premium (credit unlock).

+ + +
+ + + + diff --git a/scripts/build_kenney_3d_characters.py b/scripts/build_kenney_3d_characters.py new file mode 100644 index 0000000..aca1db0 --- /dev/null +++ b/scripts/build_kenney_3d_characters.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Build runtime GLBs for Kenney "Animated Characters 1". + +Run with Blender, not plain Python: + + blender --background --python scripts/build_kenney_3d_characters.py + +The source FBX files remain in the local Kenney bundle. Outputs are written under +html/assets/generated/ (gitignored) for CDN/local delivery. +""" +from pathlib import Path +import bpy + + +ROOT = Path(__file__).resolve().parents[1] +KENNEY = Path("/home/jw/Drawings/assets/Kenney Game Assets All-in-1 1.1.0/3D assets/Animated Characters 1") +OUT = ROOT / "html/assets/generated/3d/kenney-animated-characters" + +MODEL = KENNEY / "Model/characterMedium.fbx" +ANIMS = { + "idle": KENNEY / "Animations/idle.fbx", + "run": KENNEY / "Animations/run.fbx", + "jump": KENNEY / "Animations/jump.fbx", +} +SKINS = { + "survivorMaleB": KENNEY / "Skins/survivorMaleB.png", + "survivorFemaleA": KENNEY / "Skins/survivorFemaleA.png", +} + + +def clear_scene(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete() + + +def import_fbx(path): + before = set(bpy.data.objects) + bpy.ops.import_scene.fbx(filepath=str(path), automatic_bone_orientation=True) + return [obj for obj in bpy.data.objects if obj not in before] + + +def find_armature(objects): + for obj in objects: + if obj.type == "ARMATURE": + return obj + return None + + +def assign_skin(meshes, texture_path): + image = bpy.data.images.load(str(texture_path), check_existing=True) + mat = bpy.data.materials.new(texture_path.stem) + mat.use_nodes = True + nodes = mat.node_tree.nodes + bsdf = nodes.get("Principled BSDF") + tex = nodes.new("ShaderNodeTexImage") + tex.image = image + mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) + if "Alpha" in tex.outputs and "Alpha" in bsdf.inputs: + mat.node_tree.links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"]) + mat.blend_method = "BLEND" + for mesh in meshes: + mesh.data.materials.clear() + mesh.data.materials.append(mat) + + +def append_animation(main_armature, anim_name, anim_path): + imported = import_fbx(anim_path) + anim_armature = find_armature(imported) + action = anim_armature.animation_data.action if anim_armature and anim_armature.animation_data else None + if action: + copied = action.copy() + copied.name = anim_name + main_armature.animation_data_create() + main_armature.animation_data.action = copied + track = main_armature.animation_data.nla_tracks.new() + track.name = anim_name + strip = track.strips.new(anim_name, int(copied.frame_range[0]), copied) + strip.name = anim_name + main_armature.animation_data.action = None + for obj in imported: + bpy.data.objects.remove(obj, do_unlink=True) + + +def build_skin(name, texture_path): + clear_scene() + imported = import_fbx(MODEL) + armature = find_armature(imported) + if not armature: + raise RuntimeError("No armature found in characterMedium.fbx") + meshes = [obj for obj in imported if obj.type == "MESH"] + assign_skin(meshes, texture_path) + for anim_name, anim_path in ANIMS.items(): + append_animation(armature, anim_name, anim_path) + + bpy.ops.object.select_all(action="DESELECT") + for obj in imported: + obj.select_set(True) + bpy.context.view_layer.objects.active = armature + + OUT.mkdir(parents=True, exist_ok=True) + out_path = OUT / f"{name}.glb" + bpy.ops.export_scene.gltf( + filepath=str(out_path), + export_format="GLB", + use_selection=True, + export_animations=True, + export_nla_strips=True, + export_force_sampling=True, + ) + print(f"Wrote {out_path}") + + +def main(): + for name, skin in SKINS.items(): + build_skin(name, skin) + + +if __name__ == "__main__": + main() diff --git a/src/.env.example b/src/.env.example index 58b1dea..8b2fbdc 100644 --- a/src/.env.example +++ b/src/.env.example @@ -471,3 +471,11 @@ BLOCK_SOURCE=daemon # GAME_MODE= CRYPTO_TYPE=WOW + +# ============================================================================= +# Tavern & Multiplayer modes (optional) +# ============================================================================= +# Tavern: a walkable social room (chat, avatars, spectator camera). +TAVERN_ENABLED=false +# Server-tick / snapshot broadcast cadence in ms (default 100 = 10 Hz) +# TAVERN_TICK_MS=100 diff --git a/src/config/paymentConfig.js b/src/config/paymentConfig.js index 125256b..83051a9 100644 --- a/src/config/paymentConfig.js +++ b/src/config/paymentConfig.js @@ -87,6 +87,12 @@ const DEFAULT_CONFIG = Object.freeze({ happyHour: null } }, + products: { + // Standalone products that do not grant a game entry. Example: + // { id: 'pack_3d', label: '3D Character Pack', price: 500000000000n, + // grants: { packs: ['kenney-3d-characters'] } } + cosmetic: [] + }, limits: { maxGamesPerHour: 60, maxPayoutsPerDay: 100, @@ -211,6 +217,31 @@ function safeParseJson(value, fallback) { } } +function cloneGrantPayload(grants) { + if (!grants || typeof grants !== 'object') return undefined; + return cloneConfig(grants); +} + +function normalizeConfiguredProduct(product, fallback = {}) { + if (!product || typeof product !== 'object') return null; + const id = product.id || fallback.id; + if (!id) return null; + const out = { + id, + label: product.label || fallback.label || id, + price: parseAtomicValue(product.price, fallback.price || 0n) + }; + if (product.credits != null || fallback.credits != null) { + out.credits = parseInteger(product.credits, fallback.credits || 0); + } + if (product.bonus != null || fallback.bonus != null) { + out.bonus = parseInteger(product.bonus || 0, fallback.bonus || 0); + } + const grants = cloneGrantPayload(product.grants || fallback.grants); + if (grants) out.grants = grants; + return out; +} + class PaymentConfigManager { constructor(options = {}) { this.logger = options.logger || console; @@ -274,12 +305,11 @@ class PaymentConfigManager { const packageJson = safeParseJson(process.env.CREDITS_PACKAGES, null); if (Array.isArray(packageJson) && packageJson.length > 0) { - config.modes.credits.packages = packageJson.map(pkg => ({ - id: pkg.id, - credits: parseInteger(pkg.credits, 0), - price: parseAtomicValue(pkg.price, 0n), - bonus: parseInteger(pkg.bonus || 0, 0) - })).filter(pkg => pkg.id); + config.modes.credits.packages = packageJson.map(pkg => normalizeConfiguredProduct(pkg, { + credits: 0, + bonus: 0, + price: 0n + })).filter(pkg => pkg && pkg.id); } else if (process.env.CREDITS_PACKAGE_PRICE) { const overridePrice = parseAtomicValue(process.env.CREDITS_PACKAGE_PRICE, null); if (overridePrice !== null) { @@ -289,6 +319,13 @@ class PaymentConfigManager { } } + const cosmeticProducts = safeParseJson(process.env.COSMETIC_PRODUCTS, null); + if (Array.isArray(cosmeticProducts)) { + config.products.cosmetic = cosmeticProducts + .map(product => normalizeConfiguredProduct(product, { price: 0n })) + .filter(Boolean); + } + config.payouts.enabled = parseBoolean(process.env.PAYOUTS_ENABLED, config.payouts.enabled); config.payouts.rules.direct.enabled = parseBoolean(process.env.DIRECT_PAYOUTS_ENABLED, config.payouts.rules.direct.enabled); config.payouts.rules.direct.multipliers.escape = parseFloatValue(process.env.DIRECT_PAYOUT_ESCAPE, config.payouts.rules.direct.multipliers.escape); @@ -385,6 +422,14 @@ class PaymentConfigManager { } } } + for (const product of this.config.products.cosmetic || []) { + if (!product.id) { + throw new Error('Each cosmetic product requires an id'); + } + if (product.price <= 0n) { + throw new Error(`Cosmetic product ${product.id} has invalid price`); + } + } } async updateConfig(updates) { diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index fa4387d..42ffd8d 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -16,6 +16,8 @@ const { const { ValidationError, normalizeError } = require('../utils/errors'); const paymentConfig = require('../config/paymentConfig'); const money = require('../money/atomic'); +const Entitlements = require('../multiplayer/entitlements'); +const ProductGrants = require('../payments/productGrants'); // A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it // is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero @@ -263,6 +265,17 @@ class GameModeManager { }; } + getCosmeticProducts() { + const products = this.configSnapshot?.products?.cosmetic; + return Array.isArray(products) ? products : []; + } + + getCosmeticProduct(productId) { + const products = this.getCosmeticProducts(); + if (!productId) return products[0] || null; + return products.find(p => p.id === productId) || null; + } + calculatePayout(mode, { treasureFound = false } = {}) { const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase(); const usingCredits = normalizedMode === 'PAID_CREDITS'; @@ -292,12 +305,14 @@ class GameModeManager { * @param {object} packageInfo - Package info (credits, bonus, etc.) * @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed */ - async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null) { + async processProductPaymentConfirmation(socketId, paymentId, productInfo = null, receivedAmount = null) { try { // Look up user from payment record's user_id (stable across socket reconnects) // This avoids the bug where socket ID changes if user refreshes during confirmation const paymentLookup = await this.db.query(` - SELECT user_id, description, status FROM payments WHERE id = $1 + SELECT user_id, description, status, payment_type, product_id, product_grants + FROM payments + WHERE id = $1 `, [paymentId]); if (paymentLookup.rows.length === 0) { @@ -312,18 +327,29 @@ class GameModeManager { } const userId = paymentLookup.rows[0].user_id; + const paymentRow = paymentLookup.rows[0]; // Determine credits to add from package info or payment description - let creditsToAdd = 10; // Default fallback - if (packageInfo && packageInfo.credits) { - creditsToAdd = Number(packageInfo.credits) + (Number(packageInfo.bonus) || 0); + const paymentType = paymentRow.payment_type || 'credits_package'; + let fallbackCredits = paymentType === 'credits_package' ? 10 : 0; + if (productInfo && productInfo.credits) { + fallbackCredits = Number(productInfo.credits) + (Number(productInfo.bonus) || 0); } else { const desc = paymentLookup.rows[0].description || ''; const match = desc.match(/(\d+)\s*credits?/i); if (match) { - creditsToAdd = parseInt(match[1], 10) || 10; + fallbackCredits = parseInt(match[1], 10) || fallbackCredits; } } + const durableProduct = { + id: paymentRow.product_id, + grants: paymentRow.product_grants || undefined + }; + const product = productInfo || durableProduct; + const productGrants = ProductGrants.normalizeProductGrants(product, { credits: fallbackCredits }); + const creditsToAdd = productGrants.credits; + const productId = product?.id || paymentRow.product_id || paymentType; + const grantJson = ProductGrants.serializeProductGrants(productGrants); // CRITICAL: Wrap all three operations in a transaction. // If any step fails, the payment stays 'pending' and can be recovered. @@ -334,35 +360,71 @@ class GameModeManager { SET status = 'confirmed', credits_purchased = $1, confirmed_at = NOW(), - received_amount = COALESCE($3, received_amount) + received_amount = COALESCE($3, received_amount), + product_id = COALESCE(product_id, $4), + product_grants = $5::jsonb WHERE id = $2 AND status = 'pending' RETURNING id - `, [creditsToAdd, paymentId, receivedAmount]); + `, [creditsToAdd, paymentId, receivedAmount, productId, JSON.stringify(grantJson)]); if (paymentUpdateResult.rows.length === 0) { // Already processed by another instance - not an error return { alreadyProcessed: true }; } - // Step 2: Add credits to user (using payment's user_id, not socket lookup) + // Step 2: Add credits / premium tier to user (using payment's user_id, not socket lookup) const updateResult = await client.query(` UPDATE users SET credits = credits + $1, total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1, + premium_level = CASE + WHEN $3::text IS NULL THEN premium_level + ELSE $3::text + END, updated_at = NOW() WHERE id = $2 - RETURNING credits - `, [creditsToAdd, userId]); + RETURNING id, credits, total_credits_purchased, premium_level + `, [creditsToAdd, userId, productGrants.premiumLevel]); const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd; + const totalCreditsPurchased = updateResult.rows[0]?.total_credits_purchased ?? creditsToAdd; + const premiumLevel = updateResult.rows[0]?.premium_level || 'free'; - // Step 3: Record credit transaction for audit trail - await client.query(` - INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) - VALUES ($1, $2, 'package_purchase', $3, 'purchase') - `, [userId, creditsToAdd, newBalance]); + // Step 3: Record credit transaction for audit trail when credits were granted. + if (creditsToAdd > 0) { + await client.query(` + INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) + VALUES ($1, $2, 'package_purchase', $3, 'purchase') + `, [userId, creditsToAdd, newBalance]); + } - return { newBalance }; + // Step 4: Grant cosmetic/render packs. Idempotent so retrying a confirmed + // product cannot duplicate entitlements. + for (const pack of productGrants.packs) { + await client.query(` + INSERT INTO user_pack_entitlements (user_id, pack_id, source, expires_at, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb) + ON CONFLICT (user_id, pack_id) DO UPDATE + SET source = EXCLUDED.source, + expires_at = EXCLUDED.expires_at, + metadata = user_pack_entitlements.metadata || EXCLUDED.metadata + `, [ + userId, + pack.id, + pack.source || 'product_purchase', + pack.expiresAt || null, + JSON.stringify({ productId, paymentId }) + ]); + } + + const grantRows = await client.query(` + SELECT pack_id + FROM user_pack_entitlements + WHERE user_id = $1 + AND (expires_at IS NULL OR expires_at > NOW()) + `, [userId]); + + return { newBalance, totalCreditsPurchased, premiumLevel, packGrants: grantRows.rows || [] }; }); if (result.alreadyProcessed) { @@ -370,16 +432,26 @@ class GameModeManager { return { success: false, alreadyProcessed: true, reason: 'Payment already processed' }; } - console.log(`Credits package confirmed: +${creditsToAdd} credits for user ${userId}, new balance: ${result.newBalance}`); + const entitlements = Entitlements.snapshotForUser({ + id: userId, + credits: result.newBalance, + total_credits_purchased: result.totalCreditsPurchased, + premium_level: result.premiumLevel + }, result.packGrants); + + console.log(`Product payment confirmed: ${productId} (+${creditsToAdd} credits) for user ${userId}, new balance: ${result.newBalance}`); return { success: true, creditsAdded: creditsToAdd, - newBalance: result.newBalance + newBalance: result.newBalance, + totalCreditsPurchased: result.totalCreditsPurchased, + grantsApplied: grantJson, + entitlements }; } catch (error) { - const normalized = normalizeError(error, 'Failed to process credits package confirmation'); - console.error('Error processing credits package:', normalized.message); + const normalized = normalizeError(error, 'Failed to process product payment confirmation'); + console.error('Error processing product payment:', normalized.message); return { success: false, reason: normalized.safeMessage @@ -387,18 +459,23 @@ class GameModeManager { } } - async _findReusablePayment(userId, paymentType) { + async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null) { + return this.processProductPaymentConfirmation(socketId, paymentId, packageInfo, receivedAmount); + } + + async _findReusablePayment(userId, paymentType, productId = null) { if (!userId) return null; const result = await this.db.query(` - SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description + SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description, product_id, product_grants FROM payments WHERE user_id = $1 AND payment_type = $2 + AND ($3::text IS NULL OR product_id = $3) AND status = 'pending' AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1 - `, [userId, paymentType]); + `, [userId, paymentType, productId || null]); return result.rows[0] || null; } @@ -409,7 +486,10 @@ class GameModeManager { if (!packageInfo) return null; return { ...packageInfo, - price: typeof packageInfo.price === 'bigint' ? Number(packageInfo.price) : packageInfo.price + price: typeof packageInfo.price === 'bigint' ? Number(packageInfo.price) : packageInfo.price, + grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(packageInfo, { + credits: Number(packageInfo.credits || 0) + Number(packageInfo.bonus || 0) + })) }; } @@ -425,6 +505,8 @@ class GameModeManager { expiresAt: row.expires_at, paymentType, description: row.description, + productId: row.product_id || null, + grants: row.product_grants || null, package: this._serializePackageInfo(packageInfo), reused: true }; @@ -823,7 +905,7 @@ class GameModeManager { total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $2 AND credits >= $1 - RETURNING credits + RETURNING credits, total_credits_purchased `, [creditsToSpend, user.id]); if (updateRes.rows.length === 0) { @@ -831,6 +913,7 @@ class GameModeManager { } const remainingCredits = updateRes.rows[0].credits; + const totalCreditsPurchased = updateRes.rows[0].total_credits_purchased || user.total_credits_purchased || 0; await client.query(` UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2, @@ -844,7 +927,7 @@ class GameModeManager { VALUES ($1, $2, 'game_entry', $3, 'spend') `, [user.id, -creditsToSpend, remainingCredits]); - return { remainingCredits }; + return { remainingCredits, totalCreditsPurchased }; }); if (result.insufficient) { @@ -861,6 +944,7 @@ class GameModeManager { return { success: true, creditsRemaining: result.remainingCredits, + totalCreditsPurchased: result.totalCreditsPurchased, creditsSpent: creditsToSpend, effectiveMode: 'PAID_CREDITS' }; @@ -915,11 +999,14 @@ class GameModeManager { let amount; let description; let packageInfo = null; + let productId = null; + let productGrants = ProductGrants.normalizeProductGrants({}, { credits: 0 }); switch (paymentType) { case 'single_game': { amount = this.singleGamePrice; description = `${this.gameName} single game entry (${this.currencyLabel})`; + productId = 'single_game'; break; } case 'credits_package': { @@ -939,12 +1026,31 @@ class GameModeManager { const packagePrice = selectedPackage?.price ?? this.creditsPackagePrice; amount = typeof packagePrice === 'bigint' ? Number(packagePrice) : Number(packagePrice); packageInfo = selectedPackage; + productId = selectedPackage?.id || 'credits_package'; const creditCount = selectedPackage?.credits ?? 10; const bonusCredits = selectedPackage?.bonus ?? 0; + productGrants = ProductGrants.normalizeProductGrants(selectedPackage, { + credits: Number(creditCount) + Number(bonusCredits || 0) + }); const bonusText = bonusCredits > 0 ? ` (+${bonusCredits} bonus)` : ''; description = `${this.gameName} ${creditCount}${bonusText} credits package (${this.currencyLabel})`; break; } + case 'cosmetic_pack': { + const product = this.getCosmeticProduct(options.productId || options.packageId); + if (!product) { + throw new ValidationError('Unknown cosmetic product requested', { + safeMessage: 'Unsupported product requested.' + }); + } + const price = product.price ?? 0; + amount = typeof price === 'bigint' ? Number(price) : Number(price); + packageInfo = product; + productId = product.id; + productGrants = ProductGrants.normalizeProductGrants(product, { credits: 0 }); + description = `${this.gameName} ${product.label || product.id} (${this.currencyLabel})`; + break; + } default: throw new ValidationError(`Invalid payment type requested: ${paymentType}`, { safeMessage: 'Unsupported payment type requested.' @@ -952,7 +1058,7 @@ class GameModeManager { } if (reuseExisting) { - const existingRow = await this._findReusablePayment(user.id, paymentType); + const existingRow = await this._findReusablePayment(user.id, paymentType, productId); if (existingRow) { const existing = this._mapPaymentRowToRequest(existingRow, paymentType, packageInfo); if (existing && !existing.description) { @@ -983,10 +1089,21 @@ class GameModeManager { // Store payment info in database (address_index enables monitoring restoration after restart) const insertResult = await this.db.query(` - INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at, address_index) - VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8) + INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at, address_index, product_id, product_grants) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8, $9, $10::jsonb) RETURNING id, expires_at - `, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt, paymentResult.addressIndex ?? null]); + `, [ + user.id, + socketId, + paymentResult.address, + amount, + paymentType, + description, + expiresAt, + paymentResult.addressIndex ?? null, + productId, + JSON.stringify(ProductGrants.serializeProductGrants(productGrants)) + ]); const insertedRow = insertResult.rows[0]; @@ -999,6 +1116,8 @@ class GameModeManager { expiresAt: insertedRow?.expires_at || expiresAt, package: this._serializePackageInfo(packageInfo), paymentType, + productId, + grants: ProductGrants.publicGrantSummary(productGrants), description, reused: false, expiredAddresses @@ -1150,14 +1269,28 @@ class GameModeManager { const packages = this.configSnapshot?.modes?.credits?.packages || []; const creditPackages = packages.map(pkg => ({ id: pkg.id, + label: pkg.label || pkg.id, credits: pkg.credits, price: typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, bonus: pkg.bonus || 0, + grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(pkg, { + credits: Number(pkg.credits || 0) + Number(pkg.bonus || 0) + })), priceFormatted: this.formatAtomicHuman( typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, 2 ) })); + const cosmeticProducts = this.getCosmeticProducts().map(product => ({ + id: product.id, + label: product.label || product.id, + price: typeof product.price === 'bigint' ? Number(product.price) : product.price, + grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(product, { credits: 0 })), + priceFormatted: this.formatAtomicHuman( + typeof product.price === 'bigint' ? Number(product.price) : product.price, + 2 + ) + })); return { mode: this.gameMode, @@ -1172,6 +1305,7 @@ class GameModeManager { creditsPackagePrice: this.creditsPackagePrice, creditsPerGame: this.creditsPerGameCost, creditPackages: creditPackages, + cosmeticProducts: cosmeticProducts, creditsPayoutBaseValue: this.creditsPayoutBaseValue, paymentsEnabled: this.paymentsEnabled, freePlayEnabled: this.freePlayEnabled, @@ -1196,7 +1330,15 @@ class GameModeManager { // Smirk wallet integration. Smirk only works on mainnet, so it is forced off on // any test network (stagenet/testnet) regardless of SMIRK_ENABLED. smirkEnabled: process.env.SMIRK_ENABLED !== 'false' && !this.isTestNetwork, - explorerTxUrl: process.env.EXPLORER_TX_URL || null + explorerTxUrl: process.env.EXPLORER_TX_URL || null, + // Which top-level modes this instance offers. Solo (the single-player dungeon) is + // on unless explicitly disabled; Tavern and Multiplayer are opt-in. The client uses + // this to show/hide entry points, so any single mode can run on its own. + modes: { + solo: process.env.SOLO_ENABLED !== 'false', + tavern: process.env.TAVERN_ENABLED === 'true', + multiplayer: process.env.MULTIPLAYER_ENABLED === 'true' + } }; } diff --git a/src/index.js b/src/index.js index 2d85337..b8e60c3 100644 --- a/src/index.js +++ b/src/index.js @@ -74,7 +74,7 @@ app.use((req, res, next) => { "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self'", - "connect-src 'self' https://smirk.cash", + "connect-src 'self' https://smirk.cash https://cdn.socket.io https://cdn.jsdelivr.net", "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'", @@ -104,6 +104,11 @@ app.get('/', function(req, res) { res.sendFile('index.html', { root: htmlPath }); }); +// Human-friendly tavern route. The static file remains /tavern.html for direct asset serving. +app.get(['/tavern', '/tavern/'], function(req, res) { + res.sendFile('tavern.html', { root: htmlPath }); +}); + // Serve admin panel app.get('/admin', function(req, res) { res.sendFile('admin.html', { root: htmlPath }); diff --git a/src/migrations/020_user_appearance_and_pack_entitlements.sql b/src/migrations/020_user_appearance_and_pack_entitlements.sql new file mode 100644 index 0000000..f4968d4 --- /dev/null +++ b/src/migrations/020_user_appearance_and_pack_entitlements.sql @@ -0,0 +1,34 @@ +-- Migration 020: Canonical cosmetic identity and pack entitlements. +-- +-- `users.appearance` stores the normalized public cosmetic identity used by tavern, +-- future PvP rooms, and eventually single-player rendering. `user_pack_entitlements` +-- is the operator/admin override table for individual premium pack unlocks. The +-- current default premium policy still comes from users.total_credits_purchased > 0. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS appearance JSONB NOT NULL DEFAULT + '{"avatar":"default","tint":"none","equipment":{"body":"none","head":"none","shield":"none","weapon":"none"}}'::jsonb; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS premium_level VARCHAR(32) NOT NULL DEFAULT 'free'; + +CREATE TABLE IF NOT EXISTS user_pack_entitlements ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pack_id VARCHAR(80) NOT NULL, + source VARCHAR(40) NOT NULL DEFAULT 'operator', + granted_at TIMESTAMP NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (user_id, pack_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_pack_entitlements_user_id + ON user_pack_entitlements(user_id); + +CREATE INDEX IF NOT EXISTS idx_user_pack_entitlements_active + ON user_pack_entitlements(user_id, pack_id) + WHERE expires_at IS NULL; + +COMMENT ON COLUMN users.appearance IS 'Normalized cosmetic identity: avatar, tint, equipment.'; +COMMENT ON COLUMN users.premium_level IS 'Operator-defined premium tier; free by default. Credit purchases also unlock preview premium packs.'; +COMMENT ON TABLE user_pack_entitlements IS 'Per-user premium cosmetic/render pack grants for future individual pack purchases or operator unlocks.'; diff --git a/src/migrations/021_payment_product_grants.sql b/src/migrations/021_payment_product_grants.sql new file mode 100644 index 0000000..99e446a --- /dev/null +++ b/src/migrations/021_payment_product_grants.sql @@ -0,0 +1,23 @@ +-- Migration 021: Durable product grants on payments. +-- +-- A payment product may grant credits, cosmetic/render packs, and/or a premium level. +-- Persist the product id and normalized grants on the payment row so confirmation/recovery +-- does not depend on in-memory socket state. + +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS product_id VARCHAR(100); + +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS product_grants JSONB NOT NULL DEFAULT '{}'::jsonb; + +CREATE INDEX IF NOT EXISTS idx_payments_product_id + ON payments(product_id); + +-- Allow standalone cosmetic purchases without granting a game entry or credits. +ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_payment_type_check; +ALTER TABLE payments + ADD CONSTRAINT payments_payment_type_check + CHECK (payment_type IN ('single_game', 'credits_package', 'cosmetic_pack')) NOT VALID; + +COMMENT ON COLUMN payments.product_id IS 'Operator product id used to create this payment request.'; +COMMENT ON COLUMN payments.product_grants IS 'Normalized grant payload applied when this payment confirms.'; diff --git a/src/multiplayer/Occupant.js b/src/multiplayer/Occupant.js new file mode 100644 index 0000000..a1e5580 --- /dev/null +++ b/src/multiplayer/Occupant.js @@ -0,0 +1,52 @@ +/** + * Occupant — a player's avatar inside a real-time Room (Tavern or Multiplayer match). + * + * The multiplayer analogue of game/player.js, but for a shared world: many Occupants + * live in one Room and everyone sees everyone move. Kept small and dependency-free so the + * engine can be unit-tested in isolation and reused independently. + */ +class Occupant { + /** + * @param {string} id Stable identity (socket id today; Smirk/wallet key later). + * @param {object} opts + * @param {number} [opts.x] + * @param {number} [opts.y] + * @param {string} [opts.name] Display name (null = anonymous). + * @param {string} [opts.avatar] Avatar id (cosmetic; unlock-gated by Operator Policy). + * @param {object} [opts.appearance] Structured cosmetic appearance (base + tint/equipment). + * @param {string} [opts.facing] 'up' | 'down' | 'left' | 'right' + */ + constructor(id, { x = 0, y = 0, name = null, avatar = 'default', appearance = null, facing = 'down' } = {}) { + this.id = id; + this.x = x; + this.y = y; + this.name = name; + this.avatar = avatar; + this.appearance = appearance; + this.facing = facing; + this.joinedAt = Date.now(); + this.lastMoveAt = 0; + } + + moveTo(x, y, facing = null) { + this.x = x; + this.y = y; + if (facing) this.facing = facing; + this.lastMoveAt = Date.now(); + } + + /** Broadcast-safe state (everything here is public to the whole room). */ + getState() { + return { + id: this.id, + x: this.x, + y: this.y, + name: this.name, + avatar: this.avatar, + appearance: this.appearance, + facing: this.facing + }; + } +} + +module.exports = Occupant; diff --git a/src/multiplayer/Room.js b/src/multiplayer/Room.js new file mode 100644 index 0000000..880c3f4 --- /dev/null +++ b/src/multiplayer/Room.js @@ -0,0 +1,214 @@ +const { v4: uuidv4 } = require('uuid'); +const Occupant = require('./Occupant'); +const { TAVERN_LAYOUT, FLOOR_CHARS } = require('./tavernMap'); + +const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; + +/** + * Room — a shared, real-time, multi-occupant world. Shared engine for both new modes: + * + * • Tavern (type 'tavern'): a peaceful room — walk around, chat, spectate. + * • Multiplayer (type 'match'): the same engine with a dungeon, monsters, hazards and + * combat added (later milestones). + * + * Design invariants (kept transport-agnostic so the engine is testable and reusable): + * • No Socket.IO / DB / Wownerogue coupling here — game-state logic only. A manager layer + * owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. + * • Server-authoritative movement: the client requests a step; the Room decides. + * • Serialisable: snapshot()/fullState() are broadcast-safe (no engine internals). + */ +class Room { + /** + * @param {object} opts + * @param {string} [opts.id] Room id (defaults to a uuid). + * @param {string} [opts.type] 'tavern' | 'match' (informational for now). + * @param {string[]} [opts.layout] Map rows (defaults to the tavern). + * @param {boolean} [opts.solidOccupants] If true, occupants block each other's tiles + * (matches). Tavern leaves this false so players + * can't grief by blocking doorways. + * @param {number} [opts.maxOccupants] + */ + constructor({ id = null, type = 'tavern', layout = TAVERN_LAYOUT, solidOccupants = false, maxOccupants = 50, roomData = null, roomUrl = null } = {}) { + this.id = id || uuidv4(); + this.type = type; + this.solidOccupants = solidOccupants; + this.maxOccupants = maxOccupants; + this.createdAt = Date.now(); + this.tickCount = 0; + this.roomUrl = roomUrl; // client-relative URL of the designed room (if any) + + // A designed room (imported .tmx) supplies dims + a walkability grid; otherwise build + // the walkable map from the ASCII layout. + if (roomData) this._buildFromRoomData(roomData); + else this._buildMap(layout); + this.occupants = new Map(); // id -> Occupant + } + + _buildFromRoomData(rd) { + this.roomData = rd; + this.layout = null; + this.rows = rd.rows; + this.cols = rd.cols; + this.walkable = rd.walkable; + // Spawn on the walkable cells nearest the room centre, so players start together. + const cx = this.cols / 2, cy = this.rows / 2, cells = []; + for (let y = 0; y < this.rows; y++) { + for (let x = 0; x < this.cols; x++) { + if (this.walkable[y] && this.walkable[y][x] === true) { + cells.push({ x, y, d: (x - cx) * (x - cx) + (y - cy) * (y - cy) }); + } + } + } + cells.sort((a, b) => a.d - b.d); + this.spawns = cells.slice(0, 24).map(c => ({ x: c.x, y: c.y })); + if (this.spawns.length === 0) this.spawns = [{ x: 1, y: 1 }]; + } + + _buildMap(layout) { + this.layout = layout; + this.rows = layout.length; + this.cols = layout.reduce((m, r) => Math.max(m, r.length), 0); + this.spawns = []; + this.walkable = []; // walkable[y][x] === true means floor + for (let y = 0; y < this.rows; y++) { + const row = []; + for (let x = 0; x < this.cols; x++) { + const ch = layout[y][x] || '#'; // treat short rows as walled + row.push(FLOOR_CHARS.has(ch)); + if (ch === '@') this.spawns.push({ x, y }); + } + this.walkable.push(row); + } + if (this.spawns.length === 0) { + // Fall back to the first walkable tile. + for (let y = 0; y < this.rows && this.spawns.length === 0; y++) { + for (let x = 0; x < this.cols; x++) { + if (this.walkable[y][x]) { this.spawns.push({ x, y }); break; } + } + } + } + } + + isWalkable(x, y) { + if (y < 0 || y >= this.rows || x < 0 || x >= this.cols) return false; + return this.walkable[y][x] === true; + } + + isOccupied(x, y, exceptId = null) { + for (const occ of this.occupants.values()) { + if (occ.id !== exceptId && occ.x === x && occ.y === y) return true; + } + return false; + } + + _pickSpawn() { + // Prefer an unoccupied spawn tile so joiners don't stack; else any spawn. + for (const s of this.spawns) { + if (!this.isOccupied(s.x, s.y)) return s; + } + return this.spawns[0] || { x: 0, y: 0 }; + } + + /** + * @returns {Occupant|null} the occupant, or null if the room is full. + */ + addOccupant(id, { name = null, avatar = 'default', appearance = null } = {}) { + if (this.occupants.has(id)) return this.occupants.get(id); + if (this.occupants.size >= this.maxOccupants) return null; + const spawn = this._pickSpawn(); + const occ = new Occupant(id, { x: spawn.x, y: spawn.y, name, avatar, appearance }); + this.occupants.set(id, occ); + if (CONSOLE_LOGGING) { + console.log(`[Room ${String(this.id).slice(0, 8)}] +occupant ${String(id).slice(0, 6)} at (${spawn.x},${spawn.y})`); + } + return occ; + } + + removeOccupant(id) { + return this.occupants.delete(id); + } + + getOccupant(id) { + return this.occupants.get(id) || null; + } + + static facingFor(dx, dy) { + if (dx === -1) return 'left'; + if (dx === 1) return 'right'; + if (dy === -1) return 'up'; + if (dy === 1) return 'down'; + return null; + } + + /** + * Attempt a one-tile cardinal move for an occupant. Server-authoritative: the client + * only requests a direction; here we validate bounds, walls and (optionally) other + * occupants, and mutate state only if the step is legal. + * @returns {{moved:boolean, reason?:string, occupant?:object}} + */ + moveOccupant(id, dx, dy) { + const occ = this.occupants.get(id); + if (!occ) return { moved: false, reason: 'not_in_room' }; + + dx = Math.sign(dx || 0); + dy = Math.sign(dy || 0); + if (Math.abs(dx) + Math.abs(dy) !== 1) return { moved: false, reason: 'invalid_step' }; + + const nx = occ.x + dx; + const ny = occ.y + dy; + const facing = Room.facingFor(dx, dy); + + // Turn to face the attempted direction even when blocked (feels responsive). + if (!this.isWalkable(nx, ny)) { + if (facing) occ.facing = facing; + return { moved: false, reason: 'blocked', occupant: occ.getState() }; + } + if (this.solidOccupants && this.isOccupied(nx, ny, id)) { + if (facing) occ.facing = facing; + return { moved: false, reason: 'occupied', occupant: occ.getState() }; + } + + occ.moveTo(nx, ny, facing); + return { moved: true, occupant: occ.getState() }; + } + + /** + * Advance the world one server tick. For a Tavern this is currently a heartbeat + * (no autonomous entities yet). Multiplayer match rooms will drive monster movement, + * environmental hazards and combat resolution here. Returns events for the manager + * to broadcast. The hook is included from the start so those systems have a home. + * @returns {{tick:number, events:Array}} + */ + tick() { + this.tickCount++; + return { tick: this.tickCount, events: [] }; + } + + /** Occupants-only snapshot — broadcast every tick/update. */ + snapshot() { + return { + roomId: this.id, + type: this.type, + tick: this.tickCount, + occupants: Array.from(this.occupants.values()).map(o => o.getState()) + }; + } + + /** Full state including the static map — sent once when an occupant joins. */ + fullState() { + return { + ...this.snapshot(), + cols: this.cols, + rows: this.rows, + layout: this.layout, + solidOccupants: this.solidOccupants, + roomUrl: this.roomUrl || null + }; + } + + get size() { + return this.occupants.size; + } +} + +module.exports = Room; diff --git a/src/multiplayer/appearance.js b/src/multiplayer/appearance.js new file mode 100644 index 0000000..b934552 --- /dev/null +++ b/src/multiplayer/appearance.js @@ -0,0 +1,100 @@ +const COLOR_AVATARS = ['default', 'green', 'amber', 'red']; +const PREMIUM_SKINS = ['monero-knight', 'wownero-rogue', 'cypher-operative']; +const MODEL3D_AVATARS = ['kenney-survivor-male', 'kenney-survivor-female']; +const CHAR_AVATARS = [ + 'char-villager', 'char-elder', 'char-barbarian', 'char-monk', 'char-ranger', + 'char-bard', 'char-rogue', 'char-merchant', 'char-wizard', 'char-goblin' +]; + +const CHAR_TINTS = ['none', 'rose', 'teal', 'moss', 'gold', 'violet', 'ash']; +const CHAR_EQUIPMENT_SLOTS = ['body', 'head', 'shield', 'weapon']; +const CHAR_EQUIPMENT = { + body: ['none', 'robe', 'jerkin', 'mail', 'sash'], + head: ['none', 'hood', 'helm', 'horns', 'cap'], + shield: ['none', 'round', 'kite', 'tower', 'buckler'], + weapon: ['none', 'staff', 'sword', 'axe', 'bow'] +}; + +const ALL_AVATARS = [...COLOR_AVATARS, ...PREMIUM_SKINS, ...CHAR_AVATARS, ...MODEL3D_AVATARS]; +const AVATAR_PACKS = Object.freeze({ + ...PREMIUM_SKINS.reduce((out, id) => { + out[id] = 'generated-skins'; + return out; + }, {}), + ...MODEL3D_AVATARS.reduce((out, id) => { + out[id] = 'kenney-3d-characters'; + return out; + }, {}) +}); +const DEFAULT_EQUIPMENT = CHAR_EQUIPMENT_SLOTS.reduce((out, slot) => { + out[slot] = 'none'; + return out; +}, {}); + +function includes(list, id) { + return list.indexOf(id) !== -1; +} + +function isValidAvatar(id) { + return includes(ALL_AVATARS, id); +} + +function isCharAvatar(id) { + return includes(CHAR_AVATARS, id); +} + +function isPremiumAvatar(id) { + return !!AVATAR_PACKS[id]; +} + +function avatarPack(id) { + return AVATAR_PACKS[id] || null; +} + +function normalizeEquipment(input = {}) { + const out = {}; + for (const slot of CHAR_EQUIPMENT_SLOTS) { + const id = input && typeof input[slot] === 'string' ? input[slot] : 'none'; + out[slot] = includes(CHAR_EQUIPMENT[slot], id) ? id : 'none'; + } + return out; +} + +function normalizeAppearance(input = {}) { + if (input && input.appearance && !input.avatar) input = input.appearance; + if (typeof input === 'string') input = { avatar: input }; + if (!input || typeof input !== 'object') input = {}; + + let avatar = input.avatar || input.id || 'default'; + if (!isValidAvatar(avatar)) avatar = 'default'; + + if (!isCharAvatar(avatar)) { + return { avatar, tint: 'none', equipment: { ...DEFAULT_EQUIPMENT } }; + } + + const tint = includes(CHAR_TINTS, input.tint) ? input.tint : 'none'; + return { + avatar, + tint, + equipment: normalizeEquipment(input.equipment) + }; +} + +module.exports = { + COLOR_AVATARS, + PREMIUM_SKINS, + MODEL3D_AVATARS, + CHAR_AVATARS, + CHAR_TINTS, + CHAR_EQUIPMENT, + CHAR_EQUIPMENT_SLOTS, + DEFAULT_EQUIPMENT, + avatarIds: () => ALL_AVATARS.slice(), + premiumAvatarIds: () => Object.keys(AVATAR_PACKS), + avatarPack, + isValidAvatar, + isCharAvatar, + isPremiumAvatar, + normalizeEquipment, + normalizeAppearance +}; diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js new file mode 100644 index 0000000..b334041 --- /dev/null +++ b/src/multiplayer/entitlements.js @@ -0,0 +1,127 @@ +const Appearance = require('./appearance'); + +const PACKS = Object.freeze({ + 'roguelike-interior': Object.freeze({ + id: 'roguelike-interior', + label: 'Kenney Roguelike Interior', + premium: false, + projection: 'topdown' + }), + 'generated-skins': Object.freeze({ + id: 'generated-skins', + label: 'Premium Generated Skins', + premium: true, + projection: 'topdown', + unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + }), + 'iso-dungeon': Object.freeze({ + id: 'iso-dungeon', + label: 'Kenney Isometric Dungeon', + premium: true, + projection: 'iso', + unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + }), + 'kenney-3d-characters': Object.freeze({ + id: 'kenney-3d-characters', + label: 'Kenney Animated 3D Characters', + premium: true, + projection: '3d', + unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + }) +}); + +const PREMIUM_LEVELS = new Set(['credits', 'supporter', 'premium', 'operator']); + +function asNumber(v) { + const n = Number(v || 0); + return Number.isFinite(n) ? n : 0; +} + +function normalizePackGrants(grants = []) { + const out = {}; + if (Array.isArray(grants)) { + for (const g of grants) { + const id = g && (g.pack_id || g.packId || g.id); + if (id && PACKS[id]) out[id] = true; + } + return out; + } + if (grants && typeof grants === 'object') { + for (const id of Object.keys(grants)) { + if (PACKS[id] && grants[id]) out[id] = true; + } + } + return out; +} + +function levelForUser(user = {}) { + const raw = String(user.premium_level || user.premiumLevel || '').trim().toLowerCase(); + if (raw) return raw; + return asNumber(user.total_credits_purchased || user.totalCreditsPurchased) > 0 ? 'credits' : 'free'; +} + +function snapshotForUser(user = {}, packGrants = []) { + const totalCreditsPurchased = asNumber(user.total_credits_purchased || user.totalCreditsPurchased); + const credits = asNumber(user.credits); + const level = levelForUser(user); + const explicit = normalizePackGrants(packGrants); + const hasCreditUnlock = totalCreditsPurchased > 0; + const hasPremiumLevel = PREMIUM_LEVELS.has(level); + const packs = {}; + + for (const id of Object.keys(PACKS)) { + const pack = PACKS[id]; + if (!pack.premium) { + packs[id] = true; + continue; + } + const unlock = pack.unlock || {}; + const unlockedByCredits = unlock.kind === 'credits_purchase' + && hasCreditUnlock + && totalCreditsPurchased >= (unlock.minTotalCreditsPurchased || 1); + packs[id] = !!explicit[id] || unlockedByCredits || hasPremiumLevel; + } + + const premiumPackUnlocked = Object.keys(packs).some(id => PACKS[id].premium && packs[id]); + return { + premium: hasCreditUnlock || hasPremiumLevel || premiumPackUnlocked, + level: level === 'free' && hasCreditUnlock ? 'credits' : level, + credits, + totalCreditsPurchased, + packs + }; +} + +function canUsePack(entitlements = {}, packId) { + const pack = PACKS[packId]; + if (!pack) return false; + if (!pack.premium) return true; + if (entitlements.packs && Object.prototype.hasOwnProperty.call(entitlements.packs, packId)) { + return !!entitlements.packs[packId]; + } + return !!entitlements.premium; +} + +function canUseAppearance(entitlements = {}, appearanceOrAvatar = {}) { + const appearance = Appearance.normalizeAppearance(appearanceOrAvatar); + const packId = Appearance.avatarPack(appearance.avatar); + return !packId || canUsePack(entitlements, packId); +} + +function normalizeAppearance(input = {}, entitlements = {}) { + const appearance = Appearance.normalizeAppearance(input); + if (!canUseAppearance(entitlements, appearance)) { + return Appearance.normalizeAppearance('default'); + } + return appearance; +} + +module.exports = { + PACKS, + PREMIUM_LEVELS, + snapshotForUser, + canUsePack, + canUseAppearance, + normalizeAppearance, + normalizePackGrants +}; diff --git a/src/multiplayer/tavernMap.js b/src/multiplayer/tavernMap.js new file mode 100644 index 0000000..be6a0c1 --- /dev/null +++ b/src/multiplayer/tavernMap.js @@ -0,0 +1,58 @@ +/** + * Default Tavern layout — a small room with hand-placed props (bar, tables), generated + * procedurally so every row is guaranteed the same width. + * + * Legend: + * '#' wall '=' bar counter 'o' table / stool (all solid) + * '.' floor '@' spawn point (walkable) + * + * Anything NOT in FLOOR_CHARS is treated as solid by the Room engine. The layout is + * generated procedurally so every row is guaranteed the same width (ragged ASCII maps + * are a classic source of off-by-one collision bugs). + */ + +// Characters an Occupant may stand on. +const FLOOR_CHARS = new Set(['.', '@']); + +/** + * Build the default tavern as an array of equal-length strings. + * @param {number} cols + * @param {number} rows + * @returns {string[]} + */ +function buildDefaultTavern(cols = 26, rows = 13) { + const grid = []; + for (let y = 0; y < rows; y++) { + const row = new Array(cols); + for (let x = 0; x < cols; x++) { + const border = x === 0 || y === 0 || x === cols - 1 || y === rows - 1; + row[x] = border ? '#' : '.'; + } + grid.push(row); + } + + const put = (x, y, ch) => { + if (grid[y] && grid[y][x] !== undefined && grid[y][x] !== '#') grid[y][x] = ch; + }; + + // Bar counter along the top interior wall. + for (let x = 3; x <= 16; x++) put(x, 2, '='); + + // Table + stool clusters (2x2 blocks of 'o'). + const tables = [[5, 5], [12, 5], [18, 5], [5, 9], [12, 9], [18, 9]]; + for (const [tx, ty] of tables) { + put(tx, ty, 'o'); put(tx + 1, ty, 'o'); + put(tx, ty + 1, 'o'); put(tx + 1, ty + 1, 'o'); + } + + // Spawn pad near the entrance (bottom-center). Two tiles so early tests have a + // deterministic adjacent pair to exercise occupant-collision rules. + put(12, 11, '@'); + put(13, 11, '@'); + + return grid.map(r => r.join('')); +} + +const TAVERN_LAYOUT = buildDefaultTavern(); + +module.exports = { TAVERN_LAYOUT, FLOOR_CHARS, buildDefaultTavern }; diff --git a/src/network/chat/ChatProvider.js b/src/network/chat/ChatProvider.js new file mode 100644 index 0000000..0adcdde --- /dev/null +++ b/src/network/chat/ChatProvider.js @@ -0,0 +1,45 @@ +/** + * ChatProvider — abstraction over chat message delivery and history. + * + * The seam that lets the chat backend be swapped (e.g. for a Nostr channel) without changing + * callers. Application concerns — commands, moderation, rate limiting, XSS escaping — stay in + * the caller (ChatHandler / TavernManager); a provider only delivers and (optionally) stores. + * + * Scopes: + * 'global' → the whole server (the main lobby chat). + * 'tavern:' → a single Socket.IO room (tavern chat), ephemeral by default. + */ +class ChatProvider { + // eslint-disable-next-line no-empty-function + async initialize() {} + + /** + * Deliver (and optionally persist) a chat message. + * @param {object} msg + * @param {string} msg.scope 'global' or a room channel. + * @param {string} msg.username Display name / short id. + * @param {string} msg.text Message text (caller has already sanitized/escaped it). + * @param {number} [msg.ts] Timestamp (ms). + * @param {string} [msg.socketId] + * @param {number|null} [msg.userId] + */ + async publish(msg) { // eslint-disable-line no-unused-vars + throw new Error('ChatProvider.publish not implemented'); + } + + /** + * Recent messages for a scope, oldest → newest. + * @param {object} opts + * @param {string} [opts.scope] + * @param {number} [opts.limit] + * @returns {Promise} + */ + async getHistory({ scope = 'global', limit = 50 } = {}) { // eslint-disable-line no-unused-vars + return []; + } + + // eslint-disable-next-line no-empty-function + async shutdown() {} +} + +module.exports = ChatProvider; diff --git a/src/network/chat/SocketChatProvider.js b/src/network/chat/SocketChatProvider.js new file mode 100644 index 0000000..49e8e07 --- /dev/null +++ b/src/network/chat/SocketChatProvider.js @@ -0,0 +1,61 @@ +const ChatProvider = require('./ChatProvider'); + +/** + * SocketChatProvider — the default ChatProvider, backed by Socket.IO for delivery and (for + * global chat) the Postgres-backed ChatHistoryManager for persistence. + * + * Behaviour matches the pre-seam chat exactly: + * • global scope → persist via the history manager + broadcast to everyone + * (broadcastManager.broadcastChatMessage, i.e. io.emit 'chat_broadcast'). + * • room scope → deliver only to that Socket.IO room, ephemeral (no history). + * + * A history manager may be injected (so ChatHandler shares its existing instance) or omitted + * (tavern chat, which is ephemeral). When omitted, this provider persists nothing. + */ +class SocketChatProvider extends ChatProvider { + constructor({ io, broadcastManager = null, debugManager = null, historyManager = null } = {}) { + super(); + this.io = io; + this.broadcastManager = broadcastManager; + this.debugManager = debugManager; + this.history = historyManager; // null → ephemeral (no persistence) + this._ownsHistory = false; // true only if this provider created the manager + } + + async initialize() { + if (this._ownsHistory && this.history) await this.history.initialize(); + } + + async publish(msg = {}) { + const { scope = 'global', username, text, ts = Date.now(), socketId = null, userId = null } = msg; + + if (scope === 'global') { + if (this.history) { + // Fire-and-forget persistence, exactly as before (must not block delivery). + this.history.saveMessage({ socketId, username, message: text, type: 'chat', userId }) + .catch(err => console.error('Failed to save chat message:', err.message)); + } + if (this.broadcastManager) { + this.broadcastManager.broadcastChatMessage(username, text, ts, socketId); + } else { + this.io.emit('chat_broadcast', { username, message: text, timestamp: ts, socketId }); + } + return; + } + + // Room-scoped (e.g. tavern): delivered only to occupants of that room. The `scope` + // field lets a room client filter these from any global broadcasts it also receives. + this.io.to(scope).emit('chat_broadcast', { username, message: text, timestamp: ts, socketId, scope }); + } + + async getHistory({ scope = 'global', limit = 50 } = {}) { + if (scope === 'global' && this.history) return this.history.getRecentMessages(limit); + return []; + } + + async shutdown() { + if (this._ownsHistory && this.history) this.history.shutdown(); + } +} + +module.exports = SocketChatProvider; diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js index 0564c5e..666a0d8 100644 --- a/src/network/chatHandler.js +++ b/src/network/chatHandler.js @@ -4,6 +4,7 @@ */ const ChatHistoryManager = require('./chatHistoryManager'); +const SocketChatProvider = require('./chat/SocketChatProvider'); const { clientIp, stableId } = require('./rateLimitContext'); class ChatHandler { @@ -23,7 +24,18 @@ class ChatHandler { debugManager: debugManager, maxHistoryMessages: 50 }); - + + // Chat delivery + history go through a ChatProvider seam so the backend is swappable + // (e.g. a Nostr channel later). The default provider shares the ChatHistoryManager + // instance above, so ban checks / stats that read this.chatHistory keep working and + // lifecycle (initialize/shutdown) stays owned here. + this.chatProvider = new SocketChatProvider({ + io: this.io, + broadcastManager: this.broadcastManager, + debugManager: this.debugManager, + historyManager: this.chatHistory + }); + // Memory leak prevention - cleanup old timestamps this._chatLastSent = new Map(); this.chatCleanupInterval = setInterval(() => this.cleanupChatTimestamps(), 300000); // 5 minutes @@ -45,7 +57,7 @@ class ChatHandler { */ async sendChatHistoryToUser(socketId, messageCount = 50) { try { - const messages = await this.chatHistory.getRecentMessages(messageCount); + const messages = await this.chatProvider.getHistory({ scope: 'global', limit: messageCount }); if (messages.length > 0) { this.io.to(socketId).emit('chat_history', { messages }); if (this.debugManager?.CONSOLE_LOGGING) { @@ -319,18 +331,16 @@ class ChatHandler { .slice(0, MAX_MESSAGE_LENGTH); const username = socket.id.substring(0, 6); - // Save message to history with user_id (async, don't block broadcast) - this.chatHistory.saveMessage({ - socketId: socket.id, + // Deliver + persist through the chat provider (global scope). The provider saves to + // history (fire-and-forget) and broadcasts, matching the previous inline behaviour. + await this.chatProvider.publish({ + scope: 'global', username: username, - message: safe, - type: 'chat', + text: safe, + ts: now, + socketId: socket.id, userId: userId - }).catch(err => { - console.error('Failed to save chat message:', err.message); }); - - this.broadcastManager.broadcastChatMessage(username, safe, now, socket.id); } /** diff --git a/src/network/connectionHandler.js b/src/network/connectionHandler.js index 72d50a3..edec891 100644 --- a/src/network/connectionHandler.js +++ b/src/network/connectionHandler.js @@ -280,7 +280,8 @@ class ConnectionHandler { this.io.to(socket.id).emit('session_resumed', { token: sessionInfo.token, payoutAddress: sessionInfo.user.payout_address || null, - credits: sessionInfo.user.credits || 0 + credits: sessionInfo.user.credits || 0, + totalCreditsPurchased: sessionInfo.user.total_credits_purchased || 0 }); } else { this.io.to(socket.id).emit('session_token', { token: sessionInfo.token }); @@ -298,6 +299,7 @@ class ConnectionHandler { // Credits convenience push (include creditsPerGame for games remaining calculation) this.io.to(socket.id).emit('credits_update', { balance: sessionInfo.user.credits || 0, + totalCreditsPurchased: sessionInfo.user.total_credits_purchased || 0, creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1 }); @@ -391,4 +393,4 @@ class ConnectionHandler { } } -module.exports = ConnectionHandler; \ No newline at end of file +module.exports = ConnectionHandler; diff --git a/src/network/identityService.js b/src/network/identityService.js new file mode 100644 index 0000000..323ce06 --- /dev/null +++ b/src/network/identityService.js @@ -0,0 +1,112 @@ +const Appearance = require('../multiplayer/appearance'); +const Entitlements = require('../multiplayer/entitlements'); + +function parseAppearance(value) { + if (!value) return Appearance.normalizeAppearance('default'); + if (typeof value === 'string') { + try { + return Appearance.normalizeAppearance(JSON.parse(value)); + } catch (_) { + return Appearance.normalizeAppearance('default'); + } + } + return Appearance.normalizeAppearance(value); +} + +class IdentityService { + constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null } = {}) { + this.db = db || gameModeManager?.db || null; + this.gameModeManager = gameModeManager; + this.sessionManager = sessionManager; + this.debugManager = debugManager; + } + + async userForSocket(socket) { + if (!socket) return null; + if (this.gameModeManager && typeof this.gameModeManager.getOrCreateUser === 'function') { + return this.gameModeManager.getOrCreateUser(socket.id); + } + if (this.sessionManager && typeof this.sessionManager.getBySocket === 'function') { + return this.sessionManager.getBySocket(socket.id); + } + return null; + } + + async packGrantsForUser(userId) { + if (!this.db || userId == null) return []; + try { + const result = await this.db.query(` + SELECT pack_id, source, granted_at, expires_at + FROM user_pack_entitlements + WHERE user_id = $1 + AND (expires_at IS NULL OR expires_at > NOW()) + `, [userId]); + return result.rows || []; + } catch (err) { + // Fresh deployments run migrations before sockets are accepted. This fallback keeps + // tests and partially-migrated dev DBs from losing the whole connection path. + if (err && (err.code === '42P01' || /user_pack_entitlements/i.test(err.message || ''))) { + if (this.debugManager?.CONSOLE_LOGGING) { + console.warn('[IdentityService] user_pack_entitlements unavailable; using credit policy only.'); + } + return []; + } + throw err; + } + } + + async entitlementsForUser(user) { + if (!user) return Entitlements.snapshotForUser({}); + const grants = await this.packGrantsForUser(user.id); + return Entitlements.snapshotForUser(user, grants); + } + + async entitlementsForSocket(socket) { + const user = await this.userForSocket(socket); + return this.entitlementsForUser(user); + } + + async identityForSocket(socket) { + const user = await this.userForSocket(socket); + const entitlements = await this.entitlementsForUser(user); + const appearance = Entitlements.normalizeAppearance(parseAppearance(user?.appearance), entitlements); + return { + appearance, + entitlements + }; + } + + async saveAppearanceForSocket(socket, input) { + const user = await this.userForSocket(socket); + const entitlements = await this.entitlementsForUser(user); + const appearance = Entitlements.normalizeAppearance(input, entitlements); + + if (!this.db || !user?.id) { + return { appearance, entitlements }; + } + + const result = await this.db.query(` + UPDATE users + SET appearance = $1::jsonb, + updated_at = NOW() + WHERE id = $2 + RETURNING * + `, [JSON.stringify(appearance), user.id]); + + const updated = result.rows && result.rows[0] ? result.rows[0] : { ...user, appearance }; + this._refreshSessionCache(socket.id, updated); + return { + appearance: Entitlements.normalizeAppearance(parseAppearance(updated.appearance), entitlements), + entitlements + }; + } + + _refreshSessionCache(socketId, user) { + if (!socketId || !user || !this.sessionManager?.sessions) return; + if (this.sessionManager.sessions.has(socketId)) { + this.sessionManager.sessions.set(socketId, user); + } + } +} + +module.exports = IdentityService; diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index fc9a8ba..42e694e 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -72,9 +72,9 @@ class PaymentHandlers { } try { - const { type, packageId } = data; + const { type, packageId, productId } = data; const paymentType = type || data.gameMode || 'single_game'; - const options = { packageId, reuseExisting: true }; + const options = { packageId, productId, reuseExisting: true }; const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, options); const cryptoType = this.gameModeManager.cryptoType; @@ -86,7 +86,9 @@ class PaymentHandlers { paymentRequest.address, paymentRequest.amount, cryptoType, - paymentType === 'credits_package' ? 'Credits package' : 'Single game', + paymentType === 'single_game' + ? 'Single game' + : (paymentRequest.package?.label || (paymentType === 'credits_package' ? 'Credits package' : 'Product purchase')), this.gameModeManager.currencyDecimals ); } catch (qrErr) { @@ -102,6 +104,8 @@ class PaymentHandlers { currency: cryptoType, cryptoType: cryptoType, package: paymentRequest.package, + productId: paymentRequest.productId, + grants: paymentRequest.grants, paymentType: paymentType, qr: qrDataUrl, reused: !!paymentRequest.reused @@ -196,6 +200,14 @@ class PaymentHandlers { amount = Number(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice); const credits = primaryPackage?.credits ?? this.gameModeManager.creditsPerGameCost * 10; description = `${credits} credit package`; + } else if (paymentType === 'cosmetic_pack') { + const product = this.gameModeManager.getCosmeticProduct(options.productId || options.packageId); + if (!product) { + this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid product selection.'); + return; + } + amount = Number(product.price); + description = product.label || product.id; } else { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.'); return; @@ -210,6 +222,7 @@ class PaymentHandlers { const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, { reuseExisting: true, packageId: options.packageId, + productId: options.productId, userId: sessionUserId }); const reused = !!paymentRequest.reused; @@ -243,6 +256,8 @@ class PaymentHandlers { amountFormatted: formattedAmount, humanAmount: formattedAmount, paymentType, + productId: paymentRequest.productId, + grants: paymentRequest.grants, gameMode, cryptoType, description, @@ -256,7 +271,10 @@ class PaymentHandlers { ? '🔁 Existing payment request still pending. Use the details below to pay.' : `💳 PAYMENT REQUIRED (${description})`; - const statusBody = `\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`; + const nextStep = paymentType === 'single_game' + ? '🔄 Added to queue once mempool seen.' + : '🔄 Purchase applies after block confirmation.'; + const statusBody = `\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n${nextStep}\n⏰ Expires in 30 minutes.`; this.broadcastManager.sendStatusUpdate( socket.id, @@ -291,17 +309,16 @@ class PaymentHandlers { if (this.mempoolNotified.has(paymentRequest.address)) return; this.mempoolNotified.set(paymentRequest.address, Date.now()); - // Check payment type — credits_package should NOT queue for a game const mapping = this.socketPaymentMap.get(socket.id); - const isCredits = mapping && mapping.paymentType === 'credits_package'; + const isGameEntry = !mapping || mapping.paymentType === 'single_game'; - const detectMessage = isCredits - ? 'Payment detected in mempool! Awaiting block confirmation to add credits...' + const detectMessage = !isGameEntry + ? 'Payment detected in mempool! Awaiting block confirmation to apply your purchase...' : 'Payment detected in mempool! Adding you to the game queue...'; socket.emit('payment_detected', { paymentId: paymentRequest.id, message: detectMessage, amount: status.amount, confirmations: 0 }); - // Only queue for single_game payments (credits just add balance, no game queue) - if (!isCredits) { + // Only queue for single_game payments. Product purchases just apply grants. + if (isGameEntry) { const existingIdx = this.queueManager.getPlayerIndex(socket.id); if (existingIdx === -1) { // Try to get DB userId from session @@ -362,28 +379,38 @@ class PaymentHandlers { this.confirmedPayments.add(paymentRequest.id); this._confirmedTimestamps.set(paymentRequest.id, Date.now()); - // Handle credits_package: add credits to user + // Handle non-game products: add credits / cosmetic grants / premium tier. const mapping = this.socketPaymentMap.get(socket.id); - if (mapping && mapping.paymentType === 'credits_package' && this.gameModeManager) { + if (mapping && mapping.paymentType !== 'single_game' && this.gameModeManager) { try { - // processCreditsPackageConfirmation now atomically checks status='pending' - const creditsResult = await this.gameModeManager.processCreditsPackageConfirmation( + // Product confirmation atomically checks status='pending' and applies grants. + const creditsResult = await this.gameModeManager.processProductPaymentConfirmation( socket.id, paymentRequest.id, mapping.package, Math.round(status.amount || 0) ); if (creditsResult.success) { - socket.emit('credits_update', { balance: creditsResult.newBalance }); + socket.emit('credits_update', { + balance: creditsResult.newBalance, + totalCreditsPurchased: creditsResult.totalCreditsPurchased || 0, + ...(creditsResult.entitlements || {}) + }); + if (creditsResult.entitlements) { + socket.emit('identity_update', { entitlements: creditsResult.entitlements }); + } + const packs = creditsResult.grantsApplied?.packs || []; + const packText = packs.length ? `\nUnlocked: ${packs.map(p => p.id).join(', ')}` : ''; socket.emit('payment_confirmed', { paymentId: paymentRequest.id, - message: `Payment confirmed! Added ${creditsResult.creditsAdded} credits. New balance: ${creditsResult.newBalance}`, + message: `Payment confirmed! Added ${creditsResult.creditsAdded} credits. New balance: ${creditsResult.newBalance}${packText}`, creditsAdded: creditsResult.creditsAdded, newBalance: creditsResult.newBalance, + grantsApplied: creditsResult.grantsApplied, confirmations: status.confirmations }); this.broadcastManager.sendStatusUpdate(socket.id, 'success', - `✅ CREDITS PURCHASED!\n\n+${creditsResult.creditsAdded} credits added.\nNew balance: ${creditsResult.newBalance} credits.\n\nType 'enter' to start a game!`); + `✅ PURCHASE CONFIRMED!\n\n+${creditsResult.creditsAdded} credits added.\nNew balance: ${creditsResult.newBalance} credits.${packText}\n\nType 'enter' to start a game!`); } else if (creditsResult.alreadyProcessed) { // Payment was already confirmed (e.g., after server restart) - just notify user console.log(`[PaymentHandlers] Payment ${paymentRequest.id} already processed, skipping duplicate`); diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index cf7e86a..8bacace 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -16,6 +16,8 @@ const ConnectionHandler = require('./connectionHandler'); const ChatHandler = require('./chatHandler'); const QueueHandler = require('./queueHandler'); const SpectatorManager = require('./spectatorManager'); +const TavernManager = require('./tavernManager'); +const IdentityService = require('./identityService'); const SuspendedGameManager = require('./suspendedGameManager'); const MemoryManager = require('../utils/memoryManager'); const { normalizeError } = require('../utils/errors'); @@ -39,6 +41,7 @@ class SocketHandlers { 'game:queue': { window: 30000, max: 5 }, 'chat:message': { window: 10000, max: 12 }, 'address:set': { window: 300000, max: 3 }, + 'identity:update': { window: 10000, max: 8 }, 'connection:new': { window: 60000, max: 10 } } }); @@ -118,6 +121,13 @@ class SocketHandlers { sessionManager: this.sessionManager }); + this.identityService = new IdentityService({ + db: this.gameModeManager?.db, + gameModeManager: this.gameModeManager, + sessionManager: this.sessionManager, + debugManager: this.debugManager + }); + // Address / payout handling encapsulated in AddressManager this.addressManager = new AddressManager({ gameModeManager: this.gameModeManager, @@ -180,6 +190,25 @@ class SocketHandlers { // Wire spectator manager to game manager (for game over notifications) this.gameManager.setSpectatorManager(this.spectatorManager); + // Initialize tavern manager (social hangout mode). Inert unless TAVERN_ENABLED=true. + // Load the designed tavern room (imported .tmx); fall back to the procedural map if absent. + let tavernRoomData = null; + const tavernRoomUrl = 'assets/kenney/tavern_room.json'; + try { + const roomPath = require('path').join(__dirname, '../../html', tavernRoomUrl); + tavernRoomData = JSON.parse(require('fs').readFileSync(roomPath, 'utf8')); + } catch (e) { + if (this.debugManager.CONSOLE_LOGGING) console.log('Tavern room JSON not found; using default map:', e.message); + } + this.tavernManager = new TavernManager({ + io: this.io, + debugManager: this.debugManager, + roomData: tavernRoomData, + roomUrl: tavernRoomData ? tavernRoomUrl : null, + entitlementProvider: async (socket) => this._entitlementsForSocket(socket) + }); + this.tavernManager.initialize(); + // Initialize suspended game manager for reconnection support this.suspendedGameManager = new SuspendedGameManager({ debugManager: this.debugManager, @@ -336,6 +365,9 @@ class SocketHandlers { if (this.gameModeManager) { socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo()); } + this._emitIdentity(socket).catch(err => { + if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to send identity snapshot:', err.message); + }); // Send chat history to new user if (this.chatHandler && typeof this.chatHandler.sendChatHistoryToUser === 'function') { @@ -374,11 +406,15 @@ class SocketHandlers { if (sessionUser) { socket.emit('credits_update', { balance: sessionUser.credits || 0, + totalCreditsPurchased: sessionUser.total_credits_purchased || 0, creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1 }); } } catch (e) { /* ignore — credits will update on next action */ } } + this._emitIdentity(socket).catch(err => { + if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message); + }); }); socket.on('auto_start', () => this.handleAutoStart(socket)); // New handler for start button socket.on('play_free', () => this.handleAutoStart(socket, { free: true })); // Explicit free-play choice @@ -391,11 +427,24 @@ class SocketHandlers { socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data)); socket.on('get_user_credits', () => this.handleGetUserCredits(socket)); socket.on('address:update', (data) => this.handleAddressUpdate(socket, data)); + socket.on('identity:get', () => this.handleIdentityGet(socket)); + socket.on('identity:update', (data) => this.handleIdentityUpdate(socket, data)); // Spectator handlers socket.on('get_active_games', (options) => this.handleGetActiveGames(socket, options)); socket.on('spectate_game', (data) => this.handleSpectateGame(socket, data)); socket.on('leave_spectate', () => this.handleLeaveSpectate(socket)); + + // Tavern handlers (social hangout mode; refused server-side unless enabled) + socket.on('tavern_join', (data) => { + Promise.resolve(this.tavernManager.join(socket, data)).catch(err => { + console.error('Tavern join failed:', err.message); + socket.emit('tavern_error', { message: 'Could not enter the tavern.' }); + }); + }); + socket.on('tavern_move', (data) => this.tavernManager.move(socket, data)); + socket.on('tavern_chat', (data) => this.tavernManager.chat(socket, data)); + socket.on('tavern_leave', () => this.tavernManager.leave(socket)); } /** @@ -497,7 +546,10 @@ class SocketHandlers { } // Emit credits_update if credits were spent if (startRes.creditsRemaining !== undefined) { - this.io.to(socket.id).emit('credits_update', { balance: startRes.creditsRemaining }); + this.io.to(socket.id).emit('credits_update', { + balance: startRes.creditsRemaining, + totalCreditsPurchased: startRes.totalCreditsPurchased || 0 + }); } } @@ -755,6 +807,11 @@ class SocketHandlers { this.spectatorManager.handleDisconnect(socketId); } + // Clean up tavern presence + if (this.tavernManager) { + this.tavernManager.handleDisconnect(socketId); + } + // Evict the cached session row LAST (after the suspend logic above read it), // so the sessions map doesn't grow unbounded with every socket ever seen. if (this.sessionManager?.removeSocket) { @@ -763,6 +820,46 @@ class SocketHandlers { }); } + async _emitIdentity(socket, extra = {}) { + if (!this.identityService || !socket) return null; + const snapshot = await this.identityService.identityForSocket(socket); + socket.emit('identity_update', { ...snapshot, ...extra }); + return snapshot; + } + + async _entitlementsForSocket(socket) { + if (!this.identityService || !socket) return { premium: false, level: 'free', packs: {}, totalCreditsPurchased: 0 }; + return this.identityService.entitlementsForSocket(socket); + } + + async handleIdentityGet(socket) { + try { + await this._emitIdentity(socket); + } catch (err) { + socket.emit('identity_error', { message: 'Could not load character identity.' }); + } + } + + async handleIdentityUpdate(socket, data = {}) { + try { + const rlId = stableId(socket, this.sessionManager); + const rlIp = clientIp(socket); + const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'identity:update', rlIp); + if (!rateLimitResult.allowed) { + socket.emit('identity_error', { message: 'Character changes are temporarily rate limited.' }); + return; + } + await this.rateLimiter.recordAttempt(rlId, 'identity:update', rlIp); + + const input = data && data.appearance ? data.appearance : data; + const snapshot = await this.identityService.saveAppearanceForSocket(socket, input); + socket.emit('identity_update', { ...snapshot, saved: true }); + } catch (err) { + const normalized = normalizeError(err, 'Failed to update character identity'); + socket.emit('identity_error', { message: normalized.safeMessage || 'Could not save character identity.' }); + } + } + /** * Handle debug ping */ @@ -1082,7 +1179,8 @@ class SocketHandlers { chat: this.chatHandler.getStats(), games: this.gameManager.getStats(), queue: this.queueHandler.getStats(), - spectators: this.spectatorManager ? this.spectatorManager.getStats() : null + spectators: this.spectatorManager ? this.spectatorManager.getStats() : null, + tavern: this.tavernManager ? this.tavernManager.getStats() : null }; } @@ -1095,6 +1193,10 @@ class SocketHandlers { } // Shutdown components in reverse order of initialization + if (this.tavernManager) { + this.tavernManager.shutdown(); + } + if (this.spectatorManager) { this.spectatorManager.shutdown(); } diff --git a/src/network/tavernManager.js b/src/network/tavernManager.js new file mode 100644 index 0000000..59b865b --- /dev/null +++ b/src/network/tavernManager.js @@ -0,0 +1,215 @@ +const Room = require('../multiplayer/Room'); +const Appearance = require('../multiplayer/appearance'); +const Entitlements = require('../multiplayer/entitlements'); +const SocketChatProvider = require('./chat/SocketChatProvider'); + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * TavernManager — transport and lifecycle for the Tavern mode. + * + * Owns a shared Room, a server-tick timer, and Socket.IO room broadcasts. Structured like + * SpectatorManager. Disabled unless TAVERN_ENABLED=true: when disabled, no timer runs and + * join requests are refused, so this is inert on instances that don't opt in. + * + * Movement is server-authoritative. Clients send a direction; the Room validates it. State + * is broadcast to occupants once per server tick (clients render from these snapshots). + */ +class TavernManager { + constructor({ io, debugManager, roomId = 'main', tickMs = null, roomData = null, roomUrl = null, entitlementProvider = null } = {}) { + this.io = io; + this.debugManager = debugManager; + this.enabled = process.env.TAVERN_ENABLED === 'true'; + this.roomId = roomId; + + // Snapshot broadcast cadence. Default 100ms (10 Hz); override with TAVERN_TICK_MS. + const envTick = parseInt(process.env.TAVERN_TICK_MS, 10); + this.tickMs = tickMs || (Number.isFinite(envTick) && envTick > 0 ? envTick : 100); + + this.moveCooldownMs = 60; // per-occupant flood guard + this._lastMoveAt = new Map(); // socketId -> timestamp + this.chatCooldownMs = 900; // per-occupant chat flood guard + this._lastChatAt = new Map(); // socketId -> timestamp + this._tickInterval = null; + this.entitlementProvider = entitlementProvider; + + this.room = new Room({ id: this.roomId, type: 'tavern', roomData: roomData, roomUrl: roomUrl }); + + // Tavern chat is ephemeral (no history) and delivered only to the tavern room, via the + // same ChatProvider seam the global chat uses. + this.chatProvider = new SocketChatProvider({ io: this.io, debugManager: this.debugManager }); + } + + get channel() { + return `tavern:${this.roomId}`; + } + + /** Start the server tick. No-op (and no timer) unless enabled. */ + initialize() { + if (!this.enabled) return; + this._tickInterval = setInterval(() => this._tick(), this.tickMs); + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`Tavern enabled (room '${this.roomId}', tick ${this.tickMs}ms)`); + } + } + + _tick() { + this.room.tick(); + if (this.room.size > 0) { + this.io.to(this.channel).emit('tavern_update', this.room.snapshot()); + } + } + + _sanitizeName(name) { + if (typeof name !== 'string') return null; + // Keep letters/numbers/space and a few separators; cap length. + const cleaned = name.replace(/[^\p{L}\p{N} _.-]/gu, '').trim().slice(0, 16); + return cleaned.length ? cleaned : null; + } + + _validateAvatar(avatar) { + return Appearance.isValidAvatar(avatar) ? avatar : 'default'; + } + + async _entitlementsFor(socket) { + if (typeof this.entitlementProvider !== 'function') return { premium: false, totalCreditsPurchased: 0 }; + try { + return await this.entitlementProvider(socket); + } catch (_) { + return { premium: false, totalCreditsPurchased: 0 }; + } + } + + _normalizeAppearance(data = {}, entitlements = {}) { + const raw = data.appearance || { avatar: data.avatar }; + return Entitlements.normalizeAppearance(raw, entitlements); + } + + _normalizeDir(data) { + if (data && typeof data.dir === 'string') { + switch (data.dir) { + case 'up': return { dx: 0, dy: -1 }; + case 'down': return { dx: 0, dy: 1 }; + case 'left': return { dx: -1, dy: 0 }; + case 'right': return { dx: 1, dy: 0 }; + default: return { dx: 0, dy: 0 }; + } + } + return { + dx: Math.sign(Number(data && data.dx) || 0), + dy: Math.sign(Number(data && data.dy) || 0) + }; + } + + async join(socket, data = {}) { + if (!this.enabled) { + socket.emit('tavern_error', { message: 'Tavern is not enabled on this server.' }); + return { success: false }; + } + const name = this._sanitizeName(data.name); + const entitlements = await this._entitlementsFor(socket); + const appearance = this._normalizeAppearance(data, entitlements); + const avatar = appearance.avatar; + const occ = this.room.addOccupant(socket.id, { name, avatar, appearance }); + if (!occ) { + socket.emit('tavern_error', { message: 'The tavern is full.' }); + return { success: false }; + } + socket.join(this.channel); + // Full state (map + occupants) to the joiner; the next tick shows the arrival to others. + socket.emit('tavern_joined', { you: socket.id, state: this.room.fullState() }); + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`[Tavern] +${String(socket.id).slice(0, 6)} (${this.room.size} present)`); + } + return { success: true }; + } + + move(socket, data = {}) { + if (!this.enabled) return; + if (!this.room.getOccupant(socket.id)) return; + + const now = Date.now(); + if (now - (this._lastMoveAt.get(socket.id) || 0) < this.moveCooldownMs) return; + this._lastMoveAt.set(socket.id, now); + + const { dx, dy } = this._normalizeDir(data); + if (dx === 0 && dy === 0) return; + this.room.moveOccupant(socket.id, dx, dy); // next tick broadcasts the result + } + + chat(socket, data = {}) { + if (!this.enabled) return; + const occ = this.room.getOccupant(socket.id); + if (!occ) return; // must be in the tavern to talk + + let text = (data && typeof data.text === 'string') ? data.text.trim() : ''; + if (!text) return; + if (text.length > TavernManager.MAX_CHAT_LENGTH) text = text.slice(0, TavernManager.MAX_CHAT_LENGTH); + + const now = Date.now(); + if (now - (this._lastChatAt.get(socket.id) || 0) < this.chatCooldownMs) { + // Tell the sender instead of silently dropping — a silent drop with a cleared + // input reads as "my message disappeared". + if (socket && typeof socket.emit === 'function') { + socket.emit('tavern_notice', { message: 'Easy — wait a moment before your next message.' }); + } + return; + } + this._lastChatAt.set(socket.id, now); + + const username = occ.name || String(socket.id).slice(0, 6); + // Escape here (delivery is trusted-escaped, rendered as HTML on the client). + this.chatProvider.publish({ + scope: this.channel, + username, + text: escapeHtml(text), + ts: now, + socketId: socket.id + }); + } + + leave(socket) { + this.handleDisconnect(socket.id, socket); + } + + handleDisconnect(socketId, socket = null) { + const existed = this.room.removeOccupant(socketId); + this._lastMoveAt.delete(socketId); + this._lastChatAt.delete(socketId); + if (existed) { + const sock = socket || (this.io.sockets?.sockets?.get?.(socketId)); + if (sock) sock.leave(this.channel); + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`[Tavern] -${String(socketId).slice(0, 6)} (${this.room.size} present)`); + } + } + } + + getStats() { + return { enabled: this.enabled, roomId: this.roomId, occupants: this.room.size }; + } + + shutdown() { + if (this._tickInterval) { + clearInterval(this._tickInterval); + this._tickInterval = null; + } + this._lastMoveAt.clear(); + this._lastChatAt.clear(); + } +} + +// Cosmetic avatar ids the server will accept. Availability/unlocks are enforced via +// the Entitlements policy during join. +TavernManager.AVATARS = Appearance.avatarIds(); +TavernManager.PREMIUM_AVATARS = Appearance.premiumAvatarIds(); +TavernManager.MAX_CHAT_LENGTH = 200; + +module.exports = TavernManager; diff --git a/src/package-lock.json b/src/package-lock.json index 84a3b79..536b8c0 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -7,20 +7,23 @@ "": { "name": "wownerogue", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { "axios": "^1.9.0", "dotenv": "^16.0.3", - "express": "^4.17.1", + "express": "^4.21.3", "node-pg-migrate": "^8.0.4", "pg": "^8.15.5", "qrcode": "^1.5.3", + "resend": "^4.0.0", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "tweetnacl": "^1.0.3", "uuid": "^9.0.0" }, "devDependencies": { - "jest": "^29.7.0" + "jest": "^29.7.0", + "playwright-core": "^1.61.1" } }, "node_modules/@babel/code-frame": { @@ -1010,6 +1013,37 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-email/render": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", + "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1379,28 +1413,58 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1465,6 +1529,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -1485,6 +1550,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -1739,6 +1805,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -1842,7 +1909,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -1896,6 +1962,61 @@ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -2052,6 +2173,18 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2201,38 +2334,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -2245,6 +2379,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2561,6 +2701,41 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2589,6 +2764,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3585,6 +3761,15 @@ "node": ">=6" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -3668,6 +3853,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3859,6 +4045,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -3958,6 +4145,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4018,6 +4218,15 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz", @@ -4138,6 +4347,19 @@ "node": ">=8" } }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -4181,6 +4403,21 @@ "node": ">=0.10.0" } }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -4382,11 +4619,13 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -4404,25 +4643,87 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/react-promise-suspense": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", + "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4436,6 +4737,18 @@ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, + "node_modules/resend": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz", + "integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==", + "license": "MIT", + "dependencies": { + "@react-email/render": "1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -4508,7 +4821,27 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } }, "node_modules/semver": { "version": "6.3.1", @@ -4599,13 +4932,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -4617,12 +4951,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4635,6 +4970,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -4652,6 +4988,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5149,6 +5486,12 @@ "node": ">=0.6" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -5174,6 +5517,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" diff --git a/src/package.json b/src/package.json index c22451b..46b79cd 100644 --- a/src/package.json +++ b/src/package.json @@ -32,16 +32,17 @@ "axios": "^1.9.0", "dotenv": "^16.0.3", "express": "^4.21.3", - "pg": "^8.15.5", "node-pg-migrate": "^8.0.4", + "pg": "^8.15.5", + "qrcode": "^1.5.3", + "resend": "^4.0.0", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "tweetnacl": "^1.0.3", "uuid": "^9.0.0" - ,"qrcode": "^1.5.3", - "resend": "^4.0.0", - "tweetnacl": "^1.0.3" }, "devDependencies": { - "jest": "^29.7.0" + "jest": "^29.7.0", + "playwright-core": "^1.61.1" } } diff --git a/src/payments/productGrants.js b/src/payments/productGrants.js new file mode 100644 index 0000000..0998450 --- /dev/null +++ b/src/payments/productGrants.js @@ -0,0 +1,86 @@ +const Entitlements = require('../multiplayer/entitlements'); + +function asInt(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? Math.trunc(n) : fallback; +} + +function normalizePremiumLevel(value) { + if (typeof value !== 'string') return null; + const level = value.trim().toLowerCase(); + if (!level || level === 'free') return null; + return level; +} + +function normalizePackGrant(input) { + if (!input) return null; + const grant = typeof input === 'string' ? { id: input } : input; + const id = grant.id || grant.packId || grant.pack_id; + if (!id || !Entitlements.PACKS[id]) return null; + return { + id, + expiresAt: grant.expiresAt || grant.expires_at || null, + source: grant.source || 'product_purchase' + }; +} + +function normalizeProductGrants(product = {}, fallback = {}) { + const explicit = product && typeof product.grants === 'object' && product.grants !== null + ? product.grants + : {}; + const fallbackCredits = asInt( + fallback.credits != null + ? fallback.credits + : (asInt(product.credits, 0) + asInt(product.bonus, 0)), + 0 + ); + + let credits = fallbackCredits; + if (explicit.credits != null) { + if (typeof explicit.credits === 'object') { + credits = asInt(explicit.credits.amount, fallbackCredits); + } else { + credits = asInt(explicit.credits, fallbackCredits); + } + } + + const packs = []; + const packInputs = Array.isArray(explicit.packs) ? explicit.packs : []; + for (const p of packInputs) { + const normalized = normalizePackGrant(p); + if (normalized && !packs.some(existing => existing.id === normalized.id)) { + packs.push(normalized); + } + } + + return { + credits: Math.max(0, credits), + packs, + premiumLevel: normalizePremiumLevel(explicit.premiumLevel || explicit.premium_level) + }; +} + +function serializeProductGrants(grants = {}) { + return { + credits: asInt(grants.credits, 0), + packs: Array.isArray(grants.packs) + ? grants.packs.map(normalizePackGrant).filter(Boolean) + : [], + premiumLevel: normalizePremiumLevel(grants.premiumLevel) + }; +} + +function publicGrantSummary(grants = {}) { + const normalized = serializeProductGrants(grants); + return { + credits: normalized.credits, + packs: normalized.packs.map(p => p.id), + premiumLevel: normalized.premiumLevel + }; +} + +module.exports = { + normalizeProductGrants, + serializeProductGrants, + publicGrantSummary +}; diff --git a/test/appearance.test.js b/test/appearance.test.js new file mode 100644 index 0000000..6302bc0 --- /dev/null +++ b/test/appearance.test.js @@ -0,0 +1,35 @@ +const Appearance = require('../src/multiplayer/appearance'); + +describe('appearance catalog normalization', () => { + test('normalizes character tint and equipment slots', () => { + expect(Appearance.normalizeAppearance({ + avatar: 'char-ranger', + tint: 'moss', + equipment: { body: 'mail', head: 'helm', shield: 'bad', weapon: 'bow' } + })).toEqual({ + avatar: 'char-ranger', + tint: 'moss', + equipment: { body: 'mail', head: 'helm', shield: 'none', weapon: 'bow' } + }); + }); + + test('strips character-only fields from color and premium skin avatars', () => { + expect(Appearance.normalizeAppearance({ + avatar: 'monero-knight', + tint: 'teal', + equipment: { body: 'mail' } + })).toEqual({ + avatar: 'monero-knight', + tint: 'none', + equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' } + }); + }); + + test('falls back unknown avatars to default', () => { + expect(Appearance.normalizeAppearance('rainbow')).toEqual({ + avatar: 'default', + tint: 'none', + equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' } + }); + }); +}); diff --git a/test/chatProvider.test.js b/test/chatProvider.test.js new file mode 100644 index 0000000..d8419fe --- /dev/null +++ b/test/chatProvider.test.js @@ -0,0 +1,67 @@ +const SocketChatProvider = require('../src/network/chat/SocketChatProvider'); + +function makeFakeIo() { + const io = { + emitted: [], // { channel|null, event, payload } + to(channel) { + return { emit: (event, payload) => io.emitted.push({ channel, event, payload }) }; + }, + emit(event, payload) { io.emitted.push({ channel: null, event, payload }); } + }; + return io; +} + +describe('SocketChatProvider', () => { + test('global publish persists via history and broadcasts via broadcastManager', async () => { + const io = makeFakeIo(); + const saved = []; + const broadcast = []; + const history = { saveMessage: (m) => { saved.push(m); return Promise.resolve(); } }; + const broadcastManager = { + broadcastChatMessage: (username, text, ts, socketId) => broadcast.push({ username, text, ts, socketId }) + }; + const provider = new SocketChatProvider({ io, broadcastManager, historyManager: history }); + + await provider.publish({ scope: 'global', username: 'abc123', text: 'hi', ts: 42, socketId: 'sock', userId: 7 }); + + expect(saved).toHaveLength(1); + expect(saved[0]).toMatchObject({ socketId: 'sock', username: 'abc123', message: 'hi', userId: 7 }); + expect(broadcast).toHaveLength(1); + expect(broadcast[0]).toEqual({ username: 'abc123', text: 'hi', ts: 42, socketId: 'sock' }); + }); + + test('global publish falls back to io.emit when no broadcastManager is given', async () => { + const io = makeFakeIo(); + const provider = new SocketChatProvider({ io }); + await provider.publish({ scope: 'global', username: 'x', text: 'yo', ts: 1 }); + const ev = io.emitted.find(e => e.event === 'chat_broadcast' && e.channel === null); + expect(ev).toBeTruthy(); + expect(ev.payload).toMatchObject({ username: 'x', message: 'yo', timestamp: 1 }); + }); + + test('room-scoped publish delivers only to that room and tags the scope', async () => { + const io = makeFakeIo(); + const provider = new SocketChatProvider({ io }); + await provider.publish({ scope: 'tavern:main', username: 'bob', text: 'hey', ts: 5 }); + const ev = io.emitted.find(e => e.event === 'chat_broadcast'); + expect(ev.channel).toBe('tavern:main'); + expect(ev.payload).toMatchObject({ username: 'bob', message: 'hey', timestamp: 5, scope: 'tavern:main' }); + }); + + test('getHistory: global reads the history manager; room scopes are ephemeral', async () => { + const io = makeFakeIo(); + const history = { getRecentMessages: (n) => Promise.resolve([{ message: 'old', n }]) }; + const provider = new SocketChatProvider({ io, historyManager: history }); + await expect(provider.getHistory({ scope: 'global', limit: 10 })).resolves.toEqual([{ message: 'old', n: 10 }]); + await expect(provider.getHistory({ scope: 'tavern:main' })).resolves.toEqual([]); + }); + + test('an injected history manager is not shut down by the provider (caller owns it)', async () => { + const io = makeFakeIo(); + let shut = false; + const history = { shutdown: () => { shut = true; } }; + const provider = new SocketChatProvider({ io, historyManager: history }); + await provider.shutdown(); + expect(shut).toBe(false); + }); +}); diff --git a/test/entitlements.test.js b/test/entitlements.test.js new file mode 100644 index 0000000..9061b4f --- /dev/null +++ b/test/entitlements.test.js @@ -0,0 +1,48 @@ +const Appearance = require('../src/multiplayer/appearance'); +const Entitlements = require('../src/multiplayer/entitlements'); + +describe('cosmetic entitlements', () => { + test('free users cannot select premium generated skins or 3D avatars', () => { + const entitlements = Entitlements.snapshotForUser({ credits: 0, total_credits_purchased: 0 }); + + expect(Entitlements.canUsePack(entitlements, 'generated-skins')).toBe(false); + expect(Entitlements.canUsePack(entitlements, 'kenney-3d-characters')).toBe(false); + expect(Entitlements.normalizeAppearance({ avatar: 'monero-knight' }, entitlements).avatar).toBe('default'); + expect(Entitlements.normalizeAppearance({ avatar: 'kenney-survivor-male' }, entitlements).avatar).toBe('default'); + }); + + test('any credits purchase unlocks the preview premium packs', () => { + const entitlements = Entitlements.snapshotForUser({ credits: 7, total_credits_purchased: 10 }); + + expect(entitlements.premium).toBe(true); + expect(entitlements.level).toBe('credits'); + expect(entitlements.packs['generated-skins']).toBe(true); + expect(entitlements.packs['iso-dungeon']).toBe(true); + expect(entitlements.packs['kenney-3d-characters']).toBe(true); + expect(Entitlements.normalizeAppearance({ avatar: 'monero-knight' }, entitlements).avatar).toBe('monero-knight'); + }); + + test('explicit pack grants can unlock one pack without unlocking all packs', () => { + const entitlements = Entitlements.snapshotForUser( + { credits: 0, total_credits_purchased: 0 }, + [{ pack_id: 'generated-skins' }] + ); + + expect(entitlements.premium).toBe(true); + expect(entitlements.packs['generated-skins']).toBe(true); + expect(entitlements.packs['kenney-3d-characters']).toBe(false); + expect(Entitlements.normalizeAppearance({ avatar: 'monero-knight' }, entitlements).avatar).toBe('monero-knight'); + expect(Entitlements.normalizeAppearance({ avatar: 'kenney-survivor-male' }, entitlements).avatar).toBe('default'); + }); + + test('appearance catalog reports all premium avatar ids, including 3D models', () => { + expect(Appearance.premiumAvatarIds()).toEqual(expect.arrayContaining([ + 'monero-knight', + 'wownero-rogue', + 'cypher-operative', + 'kenney-survivor-male', + 'kenney-survivor-female' + ])); + expect(Appearance.avatarPack('kenney-survivor-male')).toBe('kenney-3d-characters'); + }); +}); diff --git a/test/gameModeManager.test.js b/test/gameModeManager.test.js index 1c8e285..930472c 100644 --- a/test/gameModeManager.test.js +++ b/test/gameModeManager.test.js @@ -199,6 +199,64 @@ describe('GameModeManager', () => { expect(result.creditsAdded).toBe(15); // 10 + 5 bonus }); + test('applies bundled pack grants from package info', async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ user_id: 1, description: '', status: 'pending', payment_type: 'credits_package' }] + }); + + mockClient.query + .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }] }) // UPDATE users + .mockResolvedValueOnce({ rows: [] }) // INSERT credit_transactions + .mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement + .mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants + + const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { + id: 'bundle', + credits: 10, + grants: { + credits: 15, + packs: ['kenney-3d-characters'], + premiumLevel: 'supporter' + } + }); + + expect(result.success).toBe(true); + expect(result.creditsAdded).toBe(15); + expect(result.grantsApplied.packs.map(p => p.id)).toEqual(['kenney-3d-characters']); + expect(result.entitlements.packs['kenney-3d-characters']).toBe(true); + + const entitlementInsert = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO user_pack_entitlements/i.test(sql)); + expect(entitlementInsert).toBeDefined(); + expect(entitlementInsert[1][1]).toBe('kenney-3d-characters'); + }); + + test('applies standalone cosmetic pack product without granting credits', async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ + user_id: 1, + description: '3D pack', + status: 'pending', + payment_type: 'cosmetic_pack', + product_id: 'pack_3d', + product_grants: { credits: 0, packs: [{ id: 'kenney-3d-characters' }], premiumLevel: null } + }] + }); + + mockClient.query + .mockResolvedValueOnce({ rows: [{ id: 456 }] }) // UPDATE payments + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }] }) // UPDATE users + .mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement + .mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants + + const result = await gmm.processProductPaymentConfirmation('socket1', 456, null, 1000); + + expect(result.success).toBe(true); + expect(result.creditsAdded).toBe(0); + expect(result.entitlements.packs['kenney-3d-characters']).toBe(true); + expect(mockClient.query.mock.calls.some(([sql]) => /INSERT INTO credit_transactions/i.test(sql))).toBe(false); + }); + test('falls back to default credits when package info missing and no description', async () => { // Step 1: db.query — SELECT payment lookup with no credits in description mockDb.query.mockResolvedValueOnce({ diff --git a/test/identityService.test.js b/test/identityService.test.js new file mode 100644 index 0000000..791c68d --- /dev/null +++ b/test/identityService.test.js @@ -0,0 +1,98 @@ +const IdentityService = require('../src/network/identityService'); + +function makeSocket(id = 'sockA') { + return { id, emit: jest.fn() }; +} + +function makeDb({ user, grants = [] } = {}) { + const state = { + user: { + id: 1, + socket_id: 'sockA', + credits: 0, + total_credits_purchased: 0, + premium_level: 'free', + appearance: { + avatar: 'char-ranger', + tint: 'teal', + equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' } + }, + ...user + }, + grants + }; + const db = { + state, + query: jest.fn(async (text, params = []) => { + if (/SELECT pack_id/i.test(text)) return { rows: state.grants }; + if (/UPDATE users[\s\S]*SET appearance/i.test(text)) { + state.user = { ...state.user, appearance: JSON.parse(params[0]) }; + return { rows: [state.user] }; + } + return { rows: [] }; + }) + }; + return db; +} + +describe('IdentityService', () => { + test('loads stored appearance and entitlements for a socket user', async () => { + const db = makeDb({ user: { total_credits_purchased: 5, credits: 2 } }); + const sessionManager = { sessions: new Map([['sockA', db.state.user]]) }; + const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db }; + const svc = new IdentityService({ db, gameModeManager, sessionManager }); + + const snapshot = await svc.identityForSocket(makeSocket()); + + expect(snapshot.appearance).toEqual(db.state.user.appearance); + expect(snapshot.entitlements.premium).toBe(true); + expect(snapshot.entitlements.packs['iso-dungeon']).toBe(true); + }); + + test('saves normalized appearance and refreshes the session cache', async () => { + const db = makeDb({ user: { total_credits_purchased: 0 } }); + const sessionManager = { sessions: new Map([['sockA', db.state.user]]) }; + const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db }; + const svc = new IdentityService({ db, gameModeManager, sessionManager }); + + const snapshot = await svc.saveAppearanceForSocket(makeSocket(), { + avatar: 'char-ranger', + tint: 'bad', + equipment: { body: 'mail', head: 'bad', shield: 'round', weapon: 'laser' } + }); + + expect(snapshot.appearance).toEqual({ + avatar: 'char-ranger', + tint: 'none', + equipment: { body: 'mail', head: 'none', shield: 'round', weapon: 'none' } + }); + expect(db.state.user.appearance).toEqual(snapshot.appearance); + expect(sessionManager.sessions.get('sockA').appearance).toEqual(snapshot.appearance); + }); + + test('falls back unauthorized premium appearance before saving', async () => { + const db = makeDb({ user: { total_credits_purchased: 0 } }); + const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db }; + const svc = new IdentityService({ db, gameModeManager }); + + const snapshot = await svc.saveAppearanceForSocket(makeSocket(), { avatar: 'monero-knight' }); + + expect(snapshot.appearance.avatar).toBe('default'); + expect(db.state.user.appearance.avatar).toBe('default'); + }); + + test('allows an explicitly granted pack appearance', async () => { + const db = makeDb({ + user: { total_credits_purchased: 0 }, + grants: [{ pack_id: 'generated-skins' }] + }); + const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db }; + const svc = new IdentityService({ db, gameModeManager }); + + const snapshot = await svc.saveAppearanceForSocket(makeSocket(), { avatar: 'monero-knight' }); + + expect(snapshot.appearance.avatar).toBe('monero-knight'); + expect(snapshot.entitlements.packs['generated-skins']).toBe(true); + expect(snapshot.entitlements.packs['kenney-3d-characters']).toBe(false); + }); +}); diff --git a/test/payment_payout_comprehensive.test.js b/test/payment_payout_comprehensive.test.js index 50e8952..986dcbe 100644 --- a/test/payment_payout_comprehensive.test.js +++ b/test/payment_payout_comprehensive.test.js @@ -485,6 +485,49 @@ describe('Payment & Payout Comprehensive Tests', () => { expect(result.package).toBeDefined(); }); + test('creates cosmetic_pack payment request with durable product grants', async () => { + const user = { id: 1, credits: 0 }; + gmm.configSnapshot.products = { + cosmetic: [{ + id: 'pack_3d', + label: '3D Character Pack', + price: 250000000000n, + grants: { packs: ['kenney-3d-characters'] } + }] + }; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment + mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments + + mockWalletService.createPaymentRequest.mockResolvedValueOnce({ + address: 'wow1packpayment', + expiresAt: new Date() + }); + + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1001, expires_at: new Date() }] + }); // INSERT payment + + const result = await gmm.createPaymentRequest('socket1', 'cosmetic_pack', { productId: 'pack_3d' }); + + expect(result.address).toBe('wow1packpayment'); + expect(result.paymentType).toBe('cosmetic_pack'); + expect(result.productId).toBe('pack_3d'); + expect(result.grants.packs).toEqual(['kenney-3d-characters']); + expect(mockWalletService.createPaymentRequest).toHaveBeenCalledWith( + 250000000000, + expect.stringContaining('3D Character Pack'), + 1, + 'socket1' + ); + + const insertCall = mockDb.query.mock.calls.find(([sql]) => /INSERT INTO payments/i.test(sql)); + expect(insertCall[1][8]).toBe('pack_3d'); + expect(JSON.parse(insertCall[1][9]).packs[0].id).toBe('kenney-3d-characters'); + }); + test('reuses existing pending payment request', async () => { const user = { id: 1, credits: 0 }; gmm.singleGamePrice = 100000000000; diff --git a/test/productGrants.test.js b/test/productGrants.test.js new file mode 100644 index 0000000..ddd557c --- /dev/null +++ b/test/productGrants.test.js @@ -0,0 +1,43 @@ +const ProductGrants = require('../src/payments/productGrants'); + +describe('product grants', () => { + test('derives credit grants from credit package fields when grants are absent', () => { + const grants = ProductGrants.normalizeProductGrants({ id: 'small', credits: 10, bonus: 2 }); + + expect(grants).toMatchObject({ + credits: 12, + packs: [], + premiumLevel: null + }); + }); + + test('normalizes bundled pack grants and premium tier', () => { + const grants = ProductGrants.normalizeProductGrants({ + id: 'bundle', + credits: 10, + grants: { + credits: 15, + packs: ['generated-skins', { id: 'kenney-3d-characters' }, 'bad-pack'], + premiumLevel: 'Supporter' + } + }); + + expect(grants.credits).toBe(15); + expect(grants.packs.map(p => p.id)).toEqual(['generated-skins', 'kenney-3d-characters']); + expect(grants.premiumLevel).toBe('supporter'); + }); + + test('public summaries omit internal source/expiry fields', () => { + const summary = ProductGrants.publicGrantSummary({ + credits: 0, + packs: [{ id: 'iso-dungeon', source: 'operator', expiresAt: '2030-01-01' }], + premiumLevel: null + }); + + expect(summary).toEqual({ + credits: 0, + packs: ['iso-dungeon'], + premiumLevel: null + }); + }); +}); diff --git a/test/renderScene.test.js b/test/renderScene.test.js new file mode 100644 index 0000000..751bcc0 --- /dev/null +++ b/test/renderScene.test.js @@ -0,0 +1,38 @@ +const scene = require('../html/js/render/sceneModel'); + +describe('sceneFromTavern (render kit adapter)', () => { + test('maps layout chars to tile kinds and occupants to entities', () => { + const appearance = { + avatar: 'char-ranger', + tint: 'teal', + equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' } + }; + const state = { + cols: 3, rows: 2, + layout: ['#=@', 'o..'], + occupants: [{ id: 'a', x: 2, y: 0, avatar: 'green', appearance, facing: 'left', name: 'Al' }] + }; + const s = scene.sceneFromTavern(state, 'a'); + expect(s.cols).toBe(3); + expect(s.rows).toBe(2); + expect(s.grid[0]).toEqual(['wall', 'bar', 'floor']); // '@' is walkable floor + expect(s.grid[1]).toEqual(['table', 'floor', 'floor']); + expect(s.entities).toHaveLength(1); + expect(s.entities[0]).toMatchObject({ id: 'a', x: 2, y: 0, kind: 'avatar', avatar: 'char-ranger', appearance, facing: 'left', label: 'Al', you: true }); + expect(s.entities[0].color).toBe(scene.AVATAR_COLORS.default); + }); + + test('you-flag is false for other occupants and unknown avatars fall back to default', () => { + const s = scene.sceneFromTavern( + { cols: 1, rows: 1, layout: ['.'], occupants: [{ id: 'b', x: 0, y: 0, avatar: 'zzz' }] }, + 'a' + ); + expect(s.entities[0].you).toBe(false); + expect(s.entities[0].color).toBe(scene.AVATAR_COLORS.default); + }); + + test('unknown / out-of-legend characters are treated as walls (solid)', () => { + const s = scene.sceneFromTavern({ cols: 2, rows: 1, layout: ['?~'], occupants: [] }); + expect(s.grid[0]).toEqual(['wall', 'wall']); + }); +}); diff --git a/test/tavernManager.test.js b/test/tavernManager.test.js new file mode 100644 index 0000000..ae90726 --- /dev/null +++ b/test/tavernManager.test.js @@ -0,0 +1,228 @@ +// TavernManager tests. The manager owns transport + the server tick; we drive it with a +// fake Socket.IO io/socket so no real server or network is involved. + +function makeFakeIo() { + const io = { + emitted: [], // { channel, event, payload } + sockets: { sockets: new Map() }, + to(channel) { + return { emit: (event, payload) => io.emitted.push({ channel, event, payload }) }; + } + }; + return io; +} + +function makeFakeSocket(id, io) { + const socket = { + id, + joined: new Set(), + emitted: [], // { event, payload } + join(ch) { this.joined.add(ch); }, + leave(ch) { this.joined.delete(ch); }, + emit(event, payload) { this.emitted.push({ event, payload }); } + }; + if (io) io.sockets.sockets.set(id, socket); + return socket; +} + +function loadManager() { + // enabled is read from env in the constructor. + jest.resetModules(); + return require('../src/network/tavernManager'); +} + +describe('TavernManager', () => { + const prev = process.env.TAVERN_ENABLED; + afterAll(() => { process.env.TAVERN_ENABLED = prev; }); + + test('is inert and refuses joins when TAVERN_ENABLED is not set', async () => { + delete process.env.TAVERN_ENABLED; + const TavernManager = loadManager(); + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + mgr.initialize(); // must not start a timer + expect(mgr._tickInterval).toBeNull(); + + const s = makeFakeSocket('a', io); + const res = await mgr.join(s, { name: 'Alice' }); + expect(res.success).toBe(false); + expect(s.emitted.some(e => e.event === 'tavern_error')).toBe(true); + expect(mgr.room.size).toBe(0); + }); + + describe('when enabled', () => { + let TavernManager; + beforeAll(() => { process.env.TAVERN_ENABLED = 'true'; }); + beforeEach(() => { TavernManager = loadManager(); }); + + test('join adds an occupant and sends full state (with the map) to the joiner', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + + const res = await mgr.join(s, { name: 'Alice', avatar: 'green' }); + expect(res.success).toBe(true); + expect(mgr.room.size).toBe(1); + expect(s.joined.has(mgr.channel)).toBe(true); + + const joined = s.emitted.find(e => e.event === 'tavern_joined'); + expect(joined).toBeTruthy(); + expect(joined.payload.you).toBe('a'); + expect(Array.isArray(joined.payload.state.layout)).toBe(true); + expect(joined.payload.state.occupants.map(o => o.id)).toEqual(['a']); + }); + + test('join preserves structured character appearance', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + const appearance = { + avatar: 'char-ranger', + tint: 'teal', + equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' } + }; + + await mgr.join(s, { name: 'Alice', avatar: 'green', appearance }); + const occ = mgr.room.getOccupant('a'); + expect(occ.avatar).toBe('char-ranger'); + expect(occ.appearance).toEqual(appearance); + + const joined = s.emitted.find(e => e.event === 'tavern_joined'); + expect(joined.payload.state.occupants[0].appearance).toEqual(appearance); + }); + + test('appearance is normalized and names are sanitized', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + await mgr.join(s, { + name: ' Ann ', + avatar: 'rainbow', + appearance: { + avatar: 'char-ranger', + tint: 'bad', + equipment: { body: 'mail', head: 'bad', shield: 'round', weapon: 'laser' } + } + }); + const occ = mgr.room.getOccupant('a'); + expect(occ.avatar).toBe('char-ranger'); + expect(occ.appearance).toEqual({ + avatar: 'char-ranger', + tint: 'none', + equipment: { body: 'mail', head: 'none', shield: 'round', weapon: 'none' } + }); + expect(occ.name).not.toMatch(/[<>]/); + expect(occ.name.length).toBeLessThanOrEqual(16); + }); + + test('premium avatars require a premium entitlement', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + await mgr.join(s, { appearance: { avatar: 'monero-knight' } }); + expect(mgr.room.getOccupant('a').avatar).toBe('default'); + + const paid = new TavernManager({ + io, + entitlementProvider: async () => ({ premium: true, totalCreditsPurchased: 10 }) + }); + const s2 = makeFakeSocket('b', io); + await paid.join(s2, { appearance: { avatar: 'monero-knight' } }); + expect(paid.room.getOccupant('b').avatar).toBe('monero-knight'); + }); + + test('move is server-authoritative and honours the flood cooldown', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + await mgr.join(s, {}); + const occ = mgr.room.getOccupant('a'); + const startX = occ.x; + + mgr.move(s, { dir: 'right' }); + expect(occ.x).toBe(startX + 1); + + // Immediate second move is dropped by the cooldown. + mgr.move(s, { dir: 'right' }); + expect(occ.x).toBe(startX + 1); + }); + + test('the tick broadcasts a snapshot to the room when occupied', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + await mgr.join(s, {}); + io.emitted.length = 0; + + mgr._tick(); + const update = io.emitted.find(e => e.event === 'tavern_update' && e.channel === mgr.channel); + expect(update).toBeTruthy(); + expect(update.payload.occupants.map(o => o.id)).toEqual(['a']); + }); + + test('disconnect removes the occupant', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + await mgr.join(s, {}); + expect(mgr.room.size).toBe(1); + + mgr.handleDisconnect('a', s); + expect(mgr.room.size).toBe(0); + expect(s.joined.has(mgr.channel)).toBe(false); + }); + + test('chat from an occupant is delivered to the tavern room, HTML-escaped', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + await mgr.join(s, { name: 'Alice' }); + io.emitted.length = 0; + + mgr.chat(s, { text: 'hi there' }); + const ev = io.emitted.find(e => e.event === 'chat_broadcast' && e.channel === mgr.channel); + expect(ev).toBeTruthy(); + expect(ev.payload.username).toBe('Alice'); + expect(ev.payload.message).toBe('hi <b>there</b>'); + expect(ev.payload.scope).toBe(mgr.channel); + }); + + test('chat honours the flood cooldown', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const s = makeFakeSocket('a', io); + await mgr.join(s, {}); + io.emitted.length = 0; + mgr.chat(s, { text: 'one' }); + s.emitted.length = 0; + mgr.chat(s, { text: 'two' }); // immediate second is dropped + const msgs = io.emitted.filter(e => e.event === 'chat_broadcast'); + expect(msgs).toHaveLength(1); + expect(msgs[0].payload.message).toBe('one'); + // The sender is told, rather than the message vanishing silently. + expect(s.emitted.some(e => e.event === 'tavern_notice')).toBe(true); + }); + + test('non-occupants and empty messages cannot chat', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + const outsider = makeFakeSocket('z', io); + mgr.chat(outsider, { text: 'sneaky' }); // never joined -> ignored + + const s = makeFakeSocket('a', io); + await mgr.join(s, {}); + io.emitted.length = 0; + mgr.chat(s, { text: ' ' }); // whitespace only -> ignored + expect(io.emitted.filter(e => e.event === 'chat_broadcast')).toHaveLength(0); + }); + + test('shutdown clears the tick timer', () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); + mgr.initialize(); + expect(mgr._tickInterval).not.toBeNull(); + mgr.shutdown(); + expect(mgr._tickInterval).toBeNull(); + }); + }); +}); diff --git a/test/tavernRoom.test.js b/test/tavernRoom.test.js new file mode 100644 index 0000000..73e17d2 --- /dev/null +++ b/test/tavernRoom.test.js @@ -0,0 +1,86 @@ +const Room = require('../src/multiplayer/Room'); +const Occupant = require('../src/multiplayer/Occupant'); + +describe('Room — shared real-time engine (Tavern instance)', () => { + test('builds a rectangular walkable map with at least one spawn', () => { + const room = new Room({ id: 'test-room', type: 'tavern' }); + expect(room.rows).toBeGreaterThan(0); + expect(room.cols).toBeGreaterThan(0); + expect(room.spawns.length).toBeGreaterThan(0); + // Border is solid; a spawn tile is walkable. + expect(room.isWalkable(0, 0)).toBe(false); + const s = room.spawns[0]; + expect(room.isWalkable(s.x, s.y)).toBe(true); + }); + + test('occupants join at a spawn and appear in the snapshot', () => { + const room = new Room({ id: 'r' }); + const a = room.addOccupant('alice', { name: 'Alice', avatar: 'default' }); + room.addOccupant('bob', { name: 'Bob' }); + expect(a).toBeInstanceOf(Occupant); + expect(room.size).toBe(2); + const ids = room.snapshot().occupants.map(o => o.id).sort(); + expect(ids).toEqual(['alice', 'bob']); + }); + + test('server-authoritative movement: a valid step moves and sets facing', () => { + const room = new Room({ id: 'r' }); + const occ = room.addOccupant('alice'); + const startX = occ.x; + const res = room.moveOccupant('alice', 1, 0); // step right (spawn has open floor right) + expect(res.moved).toBe(true); + expect(occ.x).toBe(startX + 1); + expect(occ.facing).toBe('right'); + }); + + test('rejects diagonal, multi-tile and no-op requests', () => { + const room = new Room({ id: 'r' }); + room.addOccupant('alice'); + expect(room.moveOccupant('alice', 1, 1).moved).toBe(false); // diagonal + expect(room.moveOccupant('alice', 0, 0).moved).toBe(false); // no-op + expect(room.moveOccupant('ghost', 1, 0).moved).toBe(false); // not in room + }); + + test('cannot walk through walls or off the map', () => { + const room = new Room({ id: 'r' }); + const occ = room.addOccupant('alice'); + for (let i = 0; i < 40; i++) room.moveOccupant('alice', -1, 0); // slam left + expect(occ.x).toBeGreaterThanOrEqual(0); + expect(room.isWalkable(occ.x, occ.y)).toBe(true); // still on floor + expect(room.isWalkable(occ.x - 1, occ.y)).toBe(false); // wall to the left + }); + + test('match rooms (solidOccupants) block stepping onto another occupant', () => { + const match = new Room({ id: 'm', solidOccupants: true }); + const p = match.addOccupant('p'); // spawn A + const q = match.addOccupant('q'); // spawn B, adjacent to A + expect(Math.abs(p.x - q.x) + Math.abs(p.y - q.y)).toBe(1); + const res = match.moveOccupant('q', Math.sign(p.x - q.x), Math.sign(p.y - q.y)); + expect(res.moved).toBe(false); + expect(res.reason).toBe('occupied'); + }); + + test('tavern rooms let occupants share a tile (open doorways, no blocking griefing)', () => { + const tavern = new Room({ id: 't', solidOccupants: false }); + const p = tavern.addOccupant('p'); + const q = tavern.addOccupant('q'); + const res = tavern.moveOccupant('q', Math.sign(p.x - q.x), Math.sign(p.y - q.y)); + expect(res.moved).toBe(true); + expect(tavern.isOccupied(p.x, p.y)).toBe(true); // both share the tile, allowed + }); + + test('tick is a heartbeat that increments and never throws', () => { + const room = new Room({ id: 'r' }); + for (let i = 0; i < 5; i++) room.tick(); + expect(room.tickCount).toBe(5); + expect(room.snapshot().tick).toBe(5); + }); + + test('removeOccupant clears them from the snapshot', () => { + const room = new Room({ id: 'r' }); + room.addOccupant('alice'); + expect(room.removeOccupant('alice')).toBe(true); + expect(room.size).toBe(0); + expect(room.snapshot().occupants).toEqual([]); + }); +});