feat: add tavern render kit and character entitlements

This commit is contained in:
jwinterm
2026-07-05 20:57:46 -04:00
parent bb565f9cbf
commit 454e6ab69a
78 changed files with 5839 additions and 134 deletions
+5
View File
@@ -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
+12
View File
@@ -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/
+263
View File
@@ -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.
+8
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

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"
}
}
File diff suppressed because one or more lines are too long
+3
View File
@@ -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>
+16 -3
View File
@@ -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
+74
View File
@@ -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);
+255
View File
@@ -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);
+74
View File
@@ -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);
+335
View File
@@ -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);
+269
View File
@@ -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);
+42
View File
@@ -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);
+384
View File
@@ -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);
+169
View File
@@ -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);
+131
View File
@@ -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);
+77
View File
@@ -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);
+118
View File
@@ -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);
+210
View File
@@ -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);
+82
View File
@@ -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);
+102
View File
@@ -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);
+85 -3
View File
@@ -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 ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[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);
}
},
+301
View File
@@ -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>
+120
View File
@@ -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()
+8
View File
@@ -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
+51 -6
View File
@@ -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) {
+174 -32
View File
@@ -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'
}
};
}
+6 -1
View File
@@ -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.';
+52
View File
@@ -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;
+214
View File
@@ -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;
+100
View File
@@ -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
};
+127
View File
@@ -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
};
+58
View File
@@ -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 };
+45
View File
@@ -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;
+61
View File
@@ -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;
+21 -11
View File
@@ -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);
}
/**
+4 -2
View File
@@ -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;
+112
View File
@@ -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;
+44 -17
View File
@@ -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`);
+104 -2
View File
@@ -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();
}
+215
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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;
+396 -52
View File
@@ -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"
+6 -5
View File
@@ -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"
}
}
+86
View File
@@ -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
};
+35
View File
@@ -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' }
});
});
});
+67
View File
@@ -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);
});
});
+48
View File
@@ -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');
});
});
+58
View File
@@ -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({
+98
View File
@@ -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);
});
});
+43
View File
@@ -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;
+43
View File
@@ -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
});
});
});
+38
View File
@@ -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']);
});
});
+228
View File
@@ -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 &lt;b&gt;there&lt;/b&gt;');
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();
});
});
});
+86
View File
@@ -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([]);
});
});