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 0000000..cc853ea Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Idle0.png differ 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 0000000..2bb1055 Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run0.png differ 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 0000000..9987839 Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run1.png differ 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 0000000..d1fda5c Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run2.png differ 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 0000000..1d769ee Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run3.png differ diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run4.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run4.png new file mode 100644 index 0000000..a40e392 Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run4.png differ 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 0000000..567c631 Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run5.png differ 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 0000000..269cede Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run6.png differ diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run7.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run7.png new file mode 100644 index 0000000..968af2e Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run7.png differ diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run8.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run8.png new file mode 100644 index 0000000..a52cefa Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run8.png differ diff --git a/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run9.png b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run9.png new file mode 100644 index 0000000..433f6d4 Binary files /dev/null and b/html/assets/kenney/iso-dungeon/characters/male0/Male_0_Run9.png differ 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 0000000..33b6cde Binary files /dev/null and b/html/assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png differ 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 0000000..871207f Binary files /dev/null and b/html/assets/kenney/iso-dungeon/tiles/chair_S.png differ diff --git a/html/assets/kenney/iso-dungeon/tiles/chestClosed_S.png b/html/assets/kenney/iso-dungeon/tiles/chestClosed_S.png new file mode 100644 index 0000000..7f4eddf Binary files /dev/null and b/html/assets/kenney/iso-dungeon/tiles/chestClosed_S.png differ 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 0000000..ac0f55e Binary files /dev/null and b/html/assets/kenney/iso-dungeon/tiles/planks_S.png differ diff --git a/html/assets/kenney/iso-dungeon/tiles/stoneWallHalf_S.png b/html/assets/kenney/iso-dungeon/tiles/stoneWallHalf_S.png new file mode 100644 index 0000000..45f86de Binary files /dev/null and b/html/assets/kenney/iso-dungeon/tiles/stoneWallHalf_S.png differ 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 0000000..4ba7ecb Binary files /dev/null and b/html/assets/kenney/iso-dungeon/tiles/stoneWall_S.png differ 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 0000000..7901978 Binary files /dev/null and b/html/assets/kenney/iso-dungeon/tiles/stone_S.png differ diff --git a/html/assets/kenney/roguelikeChar.png b/html/assets/kenney/roguelikeChar.png new file mode 100644 index 0000000..351aabb Binary files /dev/null and b/html/assets/kenney/roguelikeChar.png differ diff --git a/html/assets/kenney/roguelikeIndoor.png b/html/assets/kenney/roguelikeIndoor.png new file mode 100644 index 0000000..d66794b Binary files /dev/null and b/html/assets/kenney/roguelikeIndoor.png differ diff --git a/html/assets/kenney/roguelikeSheet.png b/html/assets/kenney/roguelikeSheet.png new file mode 100644 index 0000000..32e606c Binary files /dev/null and b/html/assets/kenney/roguelikeSheet.png differ 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([]); + }); +});