feat: add tavern render kit and character entitlements
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -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 (<SubTexture name=...>) 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"
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,9 @@
|
||||
<button id="watchGamesButton" style="background: #053655; color: #0af; border: 2px solid #0af; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
|
||||
👁️ <span class="lbl-full">WATCH GAMES</span><span class="lbl-short">WATCH</span>
|
||||
</button>
|
||||
<button id="tavernButton" style="background: #2a0a3a; color: #d8b4fe; border: 2px solid #a855f7; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;" onclick="window.location.href='/tavern.html'">
|
||||
🍺 <span class="lbl-full">TAVERN</span><span class="lbl-short">TAVERN</span>
|
||||
</button>
|
||||
<button id="shopButton" style="background: #004400; color: #0f0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
🛒 SHOP
|
||||
</button>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 `<div style="font-size:0.78em;color:#fbbf24;margin-top:4px;margin-left:${options.indent || 0}px;">Includes: ${this.escapeHtml(parts.join(' • '))}</div>`;
|
||||
},
|
||||
|
||||
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
|
||||
? `<span style="color:#4ade80;"> +${pkg.bonus} bonus</span>`
|
||||
: '';
|
||||
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 = {
|
||||
<div style="font-size:0.8em;color:#888;margin-top:4px;margin-left:22px;">
|
||||
${perGamePrice} ${currency}/game for ${totalCredits} games
|
||||
</div>
|
||||
${grantSummary}
|
||||
</label>
|
||||
`;
|
||||
});
|
||||
@@ -262,6 +302,39 @@ const PaymentUI = {
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// === 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 += `
|
||||
<div style="padding:10px;margin:5px 0;background:#252540;border:1px solid #444;border-radius:4px;">
|
||||
<div style="display:flex;gap:10px;justify-content:space-between;align-items:center;">
|
||||
<strong style="color:#f0f0f0;">${label}</strong>
|
||||
<span style="color:#fbbf24;font-weight:bold;">${priceFormatted} ${currency}</span>
|
||||
</div>
|
||||
${grantSummary}
|
||||
<button class="mode-option" data-mode="SHOP" data-action="buy_product" data-package-id="${this.escapeHtml(product.id)}"
|
||||
style="width:100%;padding:8px;margin-top:8px;background:#334155;border:none;color:#fff;cursor:pointer;border-radius:4px;">
|
||||
Unlock Pack
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
$container.append(`
|
||||
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#1a1a2e;border:1px solid #444;border-radius:6px;">
|
||||
<div style="margin-bottom:10px;">
|
||||
<strong style="color:#f0f0f0;">Premium Packs</strong>
|
||||
<span style="font-size:0.85em;color:#888;margin-left:10px;">Cosmetics and render modes</span>
|
||||
</div>
|
||||
${productsHtml}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// === 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('<span style="color:#ff0">♻️ Using existing pending payment</span>');
|
||||
} 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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tavern - Wownerogue</title>
|
||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
||||
<!-- PixiJS (+ @pixi/unsafe-eval for the CSP) is lazy-loaded by renderModes.js only when the
|
||||
Fancy mode is first selected, so ASCII/Tiled users never download the WebGL library. -->
|
||||
<script src="js/render/sceneModel.js"></script>
|
||||
<script src="js/render/skins.js"></script>
|
||||
<script src="js/render/charCustomize.js"></script>
|
||||
<script src="js/render/atlas.js"></script>
|
||||
<script src="js/render/charSprites.js"></script>
|
||||
<script src="js/render/assetPacks.js"></script>
|
||||
<script src="js/render/designedRoom.js"></script>
|
||||
<script src="js/render/asciiRenderer.js"></script>
|
||||
<script src="js/render/tileRenderer.js"></script>
|
||||
<script src="js/render/fancyRenderer.js"></script>
|
||||
<script src="js/render/isoRenderer.js"></script>
|
||||
<script src="js/render/threeRenderer.js"></script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<style>
|
||||
:root { --bg: #0f1216; --panel: #171b21; --line: #2a313a; --text: #d7dbe0; --accent: #3fb950; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 16px; }
|
||||
h1 { font-size: 18px; margin: 4px 0 12px; }
|
||||
.muted { color: #8b949e; font-size: 13px; }
|
||||
.rk-canvas { background: #0a0c0f; border: 1px solid var(--line); border-radius: 6px;
|
||||
display: block; max-width: 100%; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 12px; margin-bottom: 12px; }
|
||||
label { font-size: 13px; margin-right: 6px; }
|
||||
input, select, button { font: inherit; background: #0a0c0f; color: var(--text);
|
||||
border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
|
||||
button { background: var(--accent); color: #08130a; border: 0; cursor: pointer; font-weight: 600; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
#status { min-height: 18px; font-size: 13px; }
|
||||
.hidden { display: none; }
|
||||
#modebar { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.modebtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-weight: 500; }
|
||||
.modebtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
|
||||
.modebtn.locked { opacity: 0.55; }
|
||||
#chatPanel { margin-top: 12px; }
|
||||
#chatLog { height: 160px; overflow-y: auto; border: 1px solid var(--line); border-radius: 4px;
|
||||
padding: 8px; background: #0a0c0f; font-size: 13px; line-height: 1.45; }
|
||||
#chatLog .cname { color: var(--accent); font-weight: 600; }
|
||||
#chatLog .cline { margin: 2px 0; word-break: break-word; }
|
||||
#chatLog .csys { color: #8b949e; font-style: italic; }
|
||||
#chatForm { display: flex; gap: 6px; margin-top: 8px; }
|
||||
#chatInput { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Tavern <span class="muted">— early preview</span></h1>
|
||||
|
||||
<div id="joinPanel" class="panel">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" maxlength="16" placeholder="anon" autocomplete="off">
|
||||
<button id="customizeBtn" type="button">🎭 Customize</button>
|
||||
<span id="appearancePreview" class="muted"></span>
|
||||
<button id="joinBtn">Enter tavern</button>
|
||||
</div>
|
||||
|
||||
<div id="modebar" class="hidden"></div>
|
||||
<div id="stage" class="hidden"></div>
|
||||
<p id="status" class="muted"></p>
|
||||
<p class="muted">Move with arrow keys or WASD. ★ modes are premium (credit unlock).</p>
|
||||
|
||||
<div id="chatPanel" class="panel hidden">
|
||||
<div id="chatLog" aria-live="polite"></div>
|
||||
<form id="chatForm" autocomplete="off">
|
||||
<input id="chatInput" maxlength="200" placeholder="Say something…">
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var socketOpts = {};
|
||||
try {
|
||||
var resumeToken = localStorage.getItem('wownerogue_token');
|
||||
if (resumeToken) socketOpts.auth = { resumeToken: resumeToken };
|
||||
} catch (_) {}
|
||||
var socket = io(socketOpts);
|
||||
var RK = window.RK;
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var joinPanel = document.getElementById('joinPanel');
|
||||
var joinBtn = document.getElementById('joinBtn');
|
||||
var stageEl = document.getElementById('stage');
|
||||
var modebar = document.getElementById('modebar');
|
||||
var chatLog = document.getElementById('chatLog');
|
||||
var chatForm = document.getElementById('chatForm');
|
||||
var chatInput = document.getElementById('chatInput');
|
||||
|
||||
// Character customization — persisted appearance identity, chosen before entering.
|
||||
var customizeBtn = document.getElementById('customizeBtn');
|
||||
var appearancePreview = document.getElementById('appearancePreview');
|
||||
var selectedAppearance = ((RK.loadIdentity && RK.loadIdentity()) || {}).appearance || RK.normalizeAppearance('default');
|
||||
function updateAppearancePreview() {
|
||||
var a = RK.appearance ? RK.appearance(selectedAppearance) : null;
|
||||
appearancePreview.textContent = a ? ('- ' + a.label + (a.premium ? ' *' : '')) : '';
|
||||
}
|
||||
customizeBtn.addEventListener('click', function () {
|
||||
RK.openCustomize(selectedAppearance, function (appearance) {
|
||||
selectedAppearance = RK.normalizeAppearance(appearance);
|
||||
updateAppearancePreview();
|
||||
socket.emit('identity:update', { appearance: selectedAppearance });
|
||||
});
|
||||
});
|
||||
updateAppearancePreview();
|
||||
|
||||
var state = { layout: null, cols: 0, rows: 0, occupants: [], you: null, channel: null };
|
||||
var currentRenderer = null;
|
||||
var currentMode = null;
|
||||
var currentScene = null;
|
||||
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
function applyEntitlements(data) {
|
||||
if (RK.setEntitlementSnapshot) RK.setEntitlementSnapshot(data || {});
|
||||
updateAppearancePreview();
|
||||
if (!modebar.classList.contains('hidden')) {
|
||||
buildModebar();
|
||||
if (currentMode && !RK.canUseMode(currentMode)) setMode('tiles');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rendering ---------------------------------------------------------------
|
||||
function rebuildScene() {
|
||||
if (!state.layout) return;
|
||||
currentScene = RK.scene.sceneFromTavern(state, state.you);
|
||||
}
|
||||
function draw() {
|
||||
if (currentRenderer && currentScene) currentRenderer.render(currentScene);
|
||||
}
|
||||
function setMode(mode) {
|
||||
if (!RK.canUseMode(mode)) { setStatus(mode + ' is a premium mode (credit unlock).'); return; }
|
||||
if (currentRenderer) { currentRenderer.destroy(); currentRenderer = null; }
|
||||
currentMode = mode;
|
||||
RK.saveMode(mode);
|
||||
updateModeButtons();
|
||||
if (mode === 'fancy' && !RK.pixiReady()) setStatus('Loading Fancy renderer…');
|
||||
// Async: Fancy lazy-loads PixiJS on first use.
|
||||
RK.createRendererAsync(mode, stageEl, {}, function (r) {
|
||||
if (currentMode !== mode) { r.destroy(); return; } // switched again while loading
|
||||
currentRenderer = r;
|
||||
draw();
|
||||
var active = r.name || mode;
|
||||
setStatus(active === mode
|
||||
? 'Render mode: ' + active
|
||||
: '“' + mode + '” unavailable here — using “' + active + '”.');
|
||||
});
|
||||
}
|
||||
function buildModebar() {
|
||||
modebar.innerHTML = '';
|
||||
RK.RENDER_MODES.forEach(function (m) {
|
||||
var b = document.createElement('button');
|
||||
b.className = 'modebtn';
|
||||
b.dataset.mode = m.id;
|
||||
b.textContent = m.label + (m.premium ? ' ★' : '');
|
||||
if (!RK.canUseMode(m.id)) { b.classList.add('locked'); b.title = 'Premium pack — buy credits to unlock'; }
|
||||
b.addEventListener('click', function () { setMode(m.id); });
|
||||
modebar.appendChild(b);
|
||||
});
|
||||
updateModeButtons();
|
||||
}
|
||||
function updateModeButtons() {
|
||||
Array.prototype.forEach.call(modebar.children, function (b) {
|
||||
b.classList.toggle('active', b.dataset.mode === currentMode);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Join / state ------------------------------------------------------------
|
||||
joinBtn.addEventListener('click', function () {
|
||||
var name = document.getElementById('name').value || '';
|
||||
var appearance = RK.normalizeAppearance(selectedAppearance);
|
||||
joinBtn.disabled = true;
|
||||
socket.emit('tavern_join', { name: name, avatar: appearance.avatar, appearance: appearance });
|
||||
});
|
||||
|
||||
socket.on('tavern_error', function (e) {
|
||||
joinBtn.disabled = false;
|
||||
setStatus((e && e.message) || 'Could not enter the tavern.');
|
||||
});
|
||||
socket.on('game_mode_info', function (data) {
|
||||
if (data && data.entitlements) applyEntitlements(data.entitlements);
|
||||
});
|
||||
socket.on('credits_update', applyEntitlements);
|
||||
socket.on('session_token', function (data) {
|
||||
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
|
||||
});
|
||||
socket.on('session_resumed', function (data) {
|
||||
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
|
||||
applyEntitlements(data || {});
|
||||
});
|
||||
socket.on('identity_update', function (data) {
|
||||
if (!data) return;
|
||||
if (data.entitlements) applyEntitlements(data.entitlements);
|
||||
if (data.appearance) {
|
||||
selectedAppearance = RK.normalizeAppearance(data.appearance);
|
||||
RK.saveIdentity(selectedAppearance);
|
||||
updateAppearancePreview();
|
||||
}
|
||||
});
|
||||
socket.on('identity_error', function (data) {
|
||||
if (data && data.message) setStatus(data.message);
|
||||
});
|
||||
|
||||
socket.on('tavern_joined', function (data) {
|
||||
state.you = data.you;
|
||||
applyFull(data.state);
|
||||
joinPanel.classList.add('hidden');
|
||||
modebar.classList.remove('hidden');
|
||||
stageEl.classList.remove('hidden');
|
||||
document.getElementById('chatPanel').classList.remove('hidden');
|
||||
rebuildScene();
|
||||
buildModebar();
|
||||
setMode(RK.loadMode('tiles'));
|
||||
// Load the designed room (if the server is using one); re-render when ready.
|
||||
if (state.roomUrl) RK.loadRoom(state.roomUrl, function () { draw(); });
|
||||
setStatus('You are in the tavern.');
|
||||
});
|
||||
|
||||
socket.on('tavern_update', function (snap) {
|
||||
if (!snap) return;
|
||||
state.occupants = snap.occupants || [];
|
||||
rebuildScene();
|
||||
draw();
|
||||
});
|
||||
|
||||
function applyFull(s) {
|
||||
state.layout = s.layout || [];
|
||||
state.cols = s.cols || (state.layout[0] ? state.layout[0].length : 0);
|
||||
state.rows = s.rows || state.layout.length;
|
||||
state.occupants = s.occupants || [];
|
||||
state.channel = 'tavern:' + (s.roomId || 'main');
|
||||
state.roomUrl = s.roomUrl || null;
|
||||
}
|
||||
|
||||
// ---- Chat --------------------------------------------------------------------
|
||||
socket.on('chat_broadcast', function (m) {
|
||||
if (!m || !state.channel || m.scope !== state.channel) return;
|
||||
addChatLine(m.username, m.message);
|
||||
});
|
||||
socket.on('tavern_notice', function (n) {
|
||||
if (n && n.message) addSystemLine(n.message);
|
||||
});
|
||||
function addSystemLine(text) {
|
||||
var line = document.createElement('div');
|
||||
line.className = 'cline csys';
|
||||
line.textContent = text;
|
||||
chatLog.appendChild(line);
|
||||
while (chatLog.childElementCount > 200) chatLog.removeChild(chatLog.firstChild);
|
||||
chatLog.scrollTop = chatLog.scrollHeight;
|
||||
}
|
||||
function addChatLine(name, message) {
|
||||
var line = document.createElement('div');
|
||||
line.className = 'cline';
|
||||
line.innerHTML = '<span class="cname"></span> ';
|
||||
line.firstChild.textContent = name || '???';
|
||||
line.insertAdjacentHTML('beforeend', message); // server-escaped
|
||||
chatLog.appendChild(line);
|
||||
while (chatLog.childElementCount > 200) chatLog.removeChild(chatLog.firstChild);
|
||||
chatLog.scrollTop = chatLog.scrollHeight;
|
||||
}
|
||||
chatForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var text = chatInput.value;
|
||||
if (text && text.trim()) socket.emit('tavern_chat', { text: text });
|
||||
chatInput.value = '';
|
||||
});
|
||||
|
||||
// ---- Input -------------------------------------------------------------------
|
||||
var KEYS = {
|
||||
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
||||
w: 'up', s: 'down', a: 'left', d: 'right', W: 'up', S: 'down', A: 'left', D: 'right'
|
||||
};
|
||||
window.addEventListener('keydown', function (e) {
|
||||
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
|
||||
var dir = KEYS[e.key];
|
||||
if (!dir || !state.you) return;
|
||||
e.preventDefault();
|
||||
socket.emit('tavern_move', { dir: dir });
|
||||
});
|
||||
|
||||
setStatus('Connecting…');
|
||||
socket.on('connect', function () {
|
||||
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
|
||||
socket.emit('identity:get');
|
||||
setStatus('Connected. Choose a name and enter.');
|
||||
});
|
||||
socket.on('disconnect', function () { setStatus('Disconnected.'); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.';
|
||||
@@ -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.';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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:<id>' → 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<Array>}
|
||||
*/
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
module.exports = ConnectionHandler;
|
||||
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
.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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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' }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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: ' <b>Ann</b> ',
|
||||
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 <b>there</b>' });
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||