mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-22 16:48:34 +02:00
350 lines
20 KiB
Markdown
350 lines
20 KiB
Markdown
# Tavern and Multiplayer
|
|
|
|
The Tavern is a shared social room; Match mode is a shared competitive dungeon. Both are built on
|
|
the same room engine, both are opt-in per instance, and neither affects the single-player path when
|
|
disabled.
|
|
|
|
Match rules, timing, fairness, and settlement have their own reference: `docs/MATCH_MODE.md`.
|
|
Ruleset definitions are documented in `docs/RULESETS.md`.
|
|
|
|
## Shared engine: one Room, two modes
|
|
|
|
Both surfaces sit on a real-time, multi-occupant `Room`:
|
|
|
|
- **Tavern**: a peaceful room. Players pick an avatar, walk around, chat, and watch live games.
|
|
- **Match**: the same room engine with a dungeon, a monster, and ruleset-selected collision and
|
|
combat behavior.
|
|
|
|
The engine under `src/multiplayer/` has no Socket.IO, database, or application 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.
|
|
|
|
`src/multiplayer/`:
|
|
|
|
- `Occupant.js`: a shared-world avatar (position, name, avatar id, facing).
|
|
- `Room.js`: map and walkability, server-authoritative one-tile movement, optional occupant
|
|
collision, a `tick()` hook for autonomous systems (a heartbeat in the Tavern), and
|
|
snapshot/full-state serialization.
|
|
- `tavernMap.js`: the default tavern layout. Rows are built to a uniform width, and one legend
|
|
drives both client tiles (`sceneModel` `TAVERN_LEGEND`/`TILE_OF`) and `Room` walkability, so the
|
|
two cannot disagree about what is solid. Walkable characters are floor, spawn, rug, chair, and
|
|
door; everything else blocks, which is what makes players weave between furniture.
|
|
- `appearance.js`: the cosmetic identity model and its normalizer.
|
|
- `entitlements.js`: the server-side entitlement policy.
|
|
- `MatchRoom.js`, `MatchState.js`, `MatchEngine.js`: match world, state, and lifecycle.
|
|
|
|
## Tavern
|
|
|
|
A social space that reuses the 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 match games
|
|
from inside the room, and a queue button lets them enter a match.
|
|
|
|
### Entering anonymously
|
|
|
|
The Tavern requires no account or login. On connection the browser registers the client and waits
|
|
for the anonymous session handshake (`session_token`, `session_resumed`, or the associated identity
|
|
response) before enabling **Enter tavern**. The token is stored as `wownerogue_token` in that
|
|
browser's `localStorage` and reused after refresh or reconnect.
|
|
|
|
The join form accepts its submit action, including Enter from the optional name field. Its button
|
|
reads `Connecting…`, `Preparing…`, or `Entering…` while the corresponding step is pending. A
|
|
disconnect or connection error resets readiness and retries automatically; a join with no server
|
|
response becomes a visible retryable error after ten seconds. Cosmetic selections are normalized and
|
|
enforced by server entitlement policy regardless of what the client sends.
|
|
|
|
## Match
|
|
|
|
A shared dungeon room using the operator-selected ruleset. Depending on the ruleset, players race to
|
|
escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end on
|
|
the first advancing block header after their active-play duration floor (`ruleset.timing.minDurationMs`),
|
|
and every match also has a hard ceiling. Match rooms enable the collision and combat rules the
|
|
ruleset selects; the Tavern does not.
|
|
|
|
Built-in rulesets are `race`, `last-alive`, `score-attack`, and `coop-escape`. A `solo-classic`
|
|
descriptor exists for catalog parity; the single-player engine does not consume it.
|
|
|
|
## Operator policy
|
|
|
|
The operator controls which modes are enabled and, for match play, the economic model. Mode
|
|
activation, the ruleset, and match economy switches are startup environment configuration: set
|
|
`SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED`, `MATCH_RULESET_ID`, or the `MATCH_*` economy
|
|
settings and restart the service. The server reads them once at startup:
|
|
it does not hot-reload match modes or rulesets, and there is no per-room
|
|
ruleset editor. The full environment template is `src/.env.example`.
|
|
|
|
Enabled modes are surfaced to clients in `game_mode_info` as
|
|
`modes: { solo, tavern, multiplayer, match }`. `solo`, `tavern`, and `multiplayer` are booleans;
|
|
`match` is an object carrying `enabled`, the available `economies`, `maxPlayers`, the
|
|
`activeRuleset`, and the full ruleset catalog. 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 Match are opt-in.
|
|
|
|
Match economic models:
|
|
|
|
| Model | Description |
|
|
|---|---|
|
|
| `free` | No entry cost or payout; competitive results use the Free/Pleb board. |
|
|
| `credits_prestige` | Spend credits; competitive results use the separate PvP Prestige board, with no crypto payout. |
|
|
| `crypto_race` | Use a backed race-entry ticket; an eligible competitive winner receives the disclosed pot less the configured fee. |
|
|
|
|
`free` is always offered. `credits_prestige` requires credits mode or free play to be enabled.
|
|
`crypto_race` additionally requires its own switch, a payout cap, a compatible ruleset, and the
|
|
global payout safety gate, all evaluated by `matchPayoutAdmissionPolicy`. Baseline defaults are
|
|
conservative (free, no payout). Operators are responsible for ensuring their selected configuration
|
|
complies with applicable law in their jurisdiction.
|
|
|
|
## Character appearance contract
|
|
|
|
The public identity shape is `appearance: { avatar, tint, equipment, colors }`. 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 and skin loading.
|
|
|
|
Normalization in `src/multiplayer/appearance.js` is total: an unknown avatar id collapses to
|
|
`default`, unknown equipment collapses to `none` per slot, and unknown tints, skin tones, and hair
|
|
colors collapse to their defaults. Flat colour avatars carry no equipment or colour detail; 3D model
|
|
avatars carry colours but no 2D equipment overlays; character avatars carry both.
|
|
|
|
The contract is renderer-agnostic:
|
|
|
|
- **Top-down grid packs** resolve `avatar` to a base tile or frame and composite `equipment`
|
|
overlays above it. `tint` recolours only the authored recolour pixels, so outlines, skin, wood,
|
|
and metal stay intact.
|
|
- **Isometric packs** keep the same `appearance` ids and supply an isometric resolver for base
|
|
frames, direction rows, anchor position, and equipment overlays. A missing item falls back by role
|
|
(`round_shield` to `shield` to `none`) rather than changing the saved identity.
|
|
- **3D packs** resolve the same ids to GLB assets, material variants, and 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.
|
|
|
|
The client may hide or label locked choices, but gating belongs at the server policy layer: identity
|
|
saves and join requests normalize unauthorized premium avatar ids to an allowed fallback.
|
|
|
|
## Entitlements and the cosmetic catalog
|
|
|
|
`src/multiplayer/entitlements.js` is the single policy module used by identity saves, tavern joins,
|
|
render-mode gating, and payment confirmation. In production the operator-owned `cosmetic_catalog`
|
|
table (migration 024) is loaded by `CatalogService` and passed in; the `DEFAULT_CATALOG` in the
|
|
module is the fallback for fresh or partly migrated databases and for unit tests.
|
|
|
|
Each catalog entry is `{ id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }`.
|
|
A pack is unlocked when any of the following holds:
|
|
|
|
- it is a free pack (tier 0, no credit threshold, not grant-only);
|
|
- the user holds an explicit grant for it in `user_pack_entitlements`;
|
|
- the user's lifetime credits purchased (`users.total_credits_purchased`, cumulative and never
|
|
decremented by play) is at or above the pack's `unlockMinCredits`;
|
|
- the user's premium tier is at or above the pack's tier.
|
|
|
|
Premium tier comes from `users.premium_level` or an active subscription tier, whichever is higher,
|
|
over the ladder `supporter` (1), `premium` (2), `operator` (3). Buying credits does not by itself
|
|
place a user on the tier ladder: `credits` maps to tier 0, and credit spend unlocks packs only
|
|
through the `unlockMinCredits` thresholds.
|
|
|
|
The seed ladder is the free `original` tile pack plus premium packs at rising lifetime-credit
|
|
thresholds: Roguelike Interior at 1, character skins at 5, isometric dungeon at 10, roguelike
|
|
dungeon tiles at 20, isometric medieval at 40, and animated 3D characters at 50. Adding a rung is a
|
|
catalog row plus assets, not a code change. See `docs/MONETIZATION.md` and `docs/RENDER_PACKS.md`.
|
|
|
|
`snapshotForUser()` returns `{ premium, level, tier, credits, totalCreditsPurchased, packs, catalog }`.
|
|
The `packs` map is authoritative for gating; `canUsePack` consults it first and only falls back to
|
|
the coarse `premium` flag when a caller supplies a legacy entitlement object without one.
|
|
|
|
## Product grants
|
|
|
|
Payments stay separated from entitlement policy:
|
|
|
|
- **Single-game entries** buy one paid game attempt and grant no packs by default.
|
|
- **Credits packages** grant credits and may bundle cosmetic 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 (see `src/.env.example`). A grant payload normalizes to
|
|
`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`, and unknown pack ids are dropped.
|
|
|
|
```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` and `identity_update`, so the same state gates character saves, tavern joins,
|
|
render modes, and match rooms.
|
|
|
|
Supporting 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/migrations/024_cosmetic_catalog.sql` and the ladder migrations that follow it: the
|
|
operator-owned catalog.
|
|
- `src/network/identityService.js`: appearance persistence and entitlement-aware normalization.
|
|
- `src/payments/productGrants.js`: grant normalization and public summaries.
|
|
|
|
## Chat
|
|
|
|
The chat backend sits behind a `ChatProvider` interface (`src/network/chat/`) so it can be swapped
|
|
without changing callers. `SocketChatProvider` is the default: Socket.IO delivery plus Postgres
|
|
history. `buildChatProvider()` returns the plain local provider unless `NOSTR_CHAT_ENABLED` is set
|
|
and `NOSTR_CHAT_SCOPE` is `global`, in which case `NostrChatProvider` layers cross-server relay fan-out
|
|
over it. See `docs/CHAT_AND_NOSTR.md`.
|
|
|
|
**Tavern chat is global chat.** When a global chat provider is injected, `tavern_chat` publishes to
|
|
the global scope, reaching every connected client and the persisted history, so it must clear the
|
|
same bar as the lobby path. `TavernManager._moderateChat` applies the chat ban list, the
|
|
reconnect-proof rate limiter (keyed on the stable session id plus IP), and `user_id` attribution
|
|
before publishing. Without those guards `tavern_chat` would be an unauthenticated bypass of lobby
|
|
moderation under a client-chosen display name. When no global provider is injected, messages stay in
|
|
the ephemeral tavern-scoped room that nobody outside it sees, and the per-occupant cooldown alone
|
|
governs them.
|
|
|
|
Message text is escaped at publish time because delivery is trusted-escaped and rendered as HTML on
|
|
the client.
|
|
|
|
Speech bubbles resolve the speaker from the occupant list **by display name**. The chat provider
|
|
publishes a short `publicId`, never the raw socket id, so anything keying bubbles off `socketId`
|
|
never fires.
|
|
|
|
## Chain ambience
|
|
|
|
The Tavern stage carries a decorative chain layer (`RK.mountChainAmbience`, `chainAmbience.js`):
|
|
fragments of the top block hash fade in around the border and drift away, a corner readout tracks
|
|
the height, and a landing block sends a ripple around the frame plus a brighter burst.
|
|
|
|
It consumes the public `blockheight` broadcast, which carries an optional cosmetic tip (`hash`,
|
|
`difficulty`, `txPoolSize`). The tip is refreshed from `get_info` only when the height actually
|
|
advances, since it is decoration and not worth an extra RPC per poll.
|
|
`RpcService.getChainTipInfo()` never throws and never feeds fairness, payouts, or match seeding, and
|
|
`DebugManager.getChainTip()` returns null under simulated blocks because there is no chain to
|
|
describe.
|
|
|
|
The layer is `pointer-events: none`, marked `aria-hidden` with the corner readout as its accessible
|
|
copy, honours `prefers-reduced-motion`, and stops spawning while the tab is hidden. A hidden tab
|
|
pauses CSS animations, so `animationend` never fires and motes would otherwise accumulate
|
|
indefinitely; the backlog is dropped on hide.
|
|
|
|
## Rendering
|
|
|
|
Rendering goes through a renderer-agnostic **scene model**: game or tavern state is adapted into a
|
|
`Scene` (a tile grid plus entities), and any renderer draws it. This lives in `html/js/render/` and
|
|
serves the Tavern, the match client, spectators, and the single-player render bridge.
|
|
|
|
Selectable render techniques (`RK.RENDER_MODES`):
|
|
|
|
| Mode | Implementation | Premium |
|
|
|---|---|---|
|
|
| `tiles` | Coloured tiles and entity sprites on canvas 2D. The default. | No |
|
|
| `ascii` | Monospace glyph grid on canvas. Always-available fallback; accessible. | No |
|
|
| `iso` | Canvas isometric projection using the isometric dungeon pack art. | Yes |
|
|
| `3d` | Three.js projection with generated GLB avatars (`idle`, `run`, `jump` clips) and a low-poly fallback when a model cannot load. | Yes |
|
|
|
|
A premium mode unlocks once the user holds any unlocked pack for that mode's projection, so
|
|
entitlement is expressed once in the catalog rather than duplicated per mode. Render mode is a
|
|
per-user persisted choice with an operator default. Because rendering is client-side this is a
|
|
cosmetic gate; premium-only *assets* are the enforceable lever.
|
|
|
|
`fancyRenderer.js` is an internal PixiJS prototype, not a registered selectable mode. No mode button
|
|
invokes it. If it is invoked directly it loads `@pixi/unsafe-eval` immediately after `pixi.js` so
|
|
shaders precompile without adding `'unsafe-eval'` to the CSP, and it requires the server's external
|
|
renderer policy to be on.
|
|
|
|
Production top-down styling comes from interchangeable packs plus the shared canvas FX layer. The
|
|
render kit provides the engine and programmatic tiles and effects; authored tilesets, sprite sheets,
|
|
and shader effects are a separate content effort.
|
|
|
|
## Asset delivery and performance
|
|
|
|
- **Heavy source artifacts are not committed.** 3D models and generation intermediates (`*.glb`,
|
|
`*.fbx`, `*.blend`) are gitignored. Only the outputs the game loads are candidates for shipping.
|
|
- **Generated and premium assets are provisioned, not committed.** Premium skins and runtime GLBs
|
|
live under `html/assets/generated/`, which is gitignored. They are provisioned into the
|
|
same-origin runtime path or delivered by an explicitly reviewed asset host and CSP policy, and are
|
|
not present in the Git release artifact. The base tier's small Kenney CC0 tiles stay in-repo.
|
|
- **Load by mode and entitlement.** 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. Generated GLBs
|
|
are emitted by `scripts/build_kenney_3d_characters.py` from local Kenney FBX sources into
|
|
`html/assets/generated/3d/`.
|
|
- **Three.js is served from this origin.** `src/index.js` publishes the browser build and addons
|
|
under `/vendor/three/0.160.0/` from the pinned `three` dependency, with immutable versioned URLs
|
|
so a cached copy cannot survive an upgrade to different bytes. Pages declare it through an import
|
|
map, and `RK.ensureThree` imports it only when 3D is selected. `RENDERER_CDN_ENABLED` defaults to
|
|
false: third-party renderer code would execute with the same privileges as the game and payment
|
|
UI, so trusting a CDN is an explicit operator decision and widens the CSP when enabled.
|
|
- **Optimize what ships.** Sprite sheets are delivered as WebP with alpha, sized to display
|
|
resolution, content-hashed with a long cache TTL. The demo walk sheet is roughly ten times smaller
|
|
as WebP than as PNG.
|
|
|
|
## Client and transport
|
|
|
|
`src/network/tavernManager.js` owns transport and lifecycle: one shared `Room`, the server-tick
|
|
timer (default 100ms, override with `TAVERN_TICK_MS`), and Socket.IO room broadcasts on the
|
|
`tavern:<roomId>` channel. It is wired into `SocketHandlers` (`tavern_join`, `tavern_move`,
|
|
`tavern_leave`, plus disconnect cleanup and shutdown) and is inert unless `TAVERN_ENABLED=true`.
|
|
|
|
`src/network/tavernMatchBridge.js` relays match progress into the Tavern (`tavern_match_tick`,
|
|
`tavern_match_end`) and requires both `TAVERN_ENABLED` and `MATCH_ENABLED`.
|
|
|
|
`html/tavern.html` is the browser client: establish or resume an anonymous session, choose an
|
|
optional name and avatar, enter the room, walk with keyboard or on-screen controls, chat, watch solo
|
|
or match games, or join a match queue. No account login is required.
|
|
|
|
`html/js/render/` is the render kit: `sceneModel.js` (renderer-agnostic adapters), the ASCII, tiled,
|
|
isometric, and Three.js renderers, the internal Fancy prototype, and `renderModes.js` (registry,
|
|
factory with graceful fallback, entitlements, persistence). Clients expose a mode toolbar where
|
|
applicable; premium modes are marked and gated by `RK.entitlements`.
|
|
|
|
## Tests
|
|
|
|
- `test/tavernRoom.test.js`: the room engine.
|
|
- `test/tavernManager.test.js`: the manager, including chat moderation.
|
|
- `test/tavernMatchBridge.test.js`: match-to-tavern relay.
|
|
- `test/chatProvider.test.js`: the provider seam.
|
|
- `test/renderScene.test.js`: the scene adapter.
|
|
- `test/entitlements.test.js`, `test/catalogEntitlements_ultra.test.js`: entitlement policy and the
|
|
operator catalog.
|
|
- `test/identityService.test.js`, `test/appearance.test.js`, `test/appearanceModel3d.test.js`:
|
|
appearance normalization and persistence.
|
|
- `test/productGrants.test.js`: grant normalization.
|
|
- `test/pvpUiPolish.test.js`: anonymous readiness and join states.
|
|
- `test/rendererCdnPolicy.test.js`: the same-origin Three.js runtime policy.
|
|
|
|
## Running locally
|
|
|
|
Set `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), start the server, and open `/tavern.html`
|
|
in two browser tabs. Add `MATCH_ENABLED=true` and a `MATCH_RULESET_ID` to exercise match entry from
|
|
the Tavern.
|
|
|
|
For the isolated, gameplay-only Tavern browser smoke, run `npm run test:showcase-browser` from
|
|
`src`. It starts a fresh loopback server and two anonymous browser contexts, with no database,
|
|
wallet, purchase, or queue/race flow. Mica receives synthetic `roguelike-interior` and `iso-dungeon`
|
|
pack grants from the source-owned default catalog to prove permitted character drawing and Iso rendering.
|
|
Moth receives the catalog's zero-grant snapshot and must retain original-only access. Both balances
|
|
remain zero; neither fixture represents a purchase or production entitlement. Screenshots and a JSON result go to a unique directory under
|
|
`~/Build/wowngeon/showcase-browser`; pass `-- --output-root <directory>` to choose another output
|
|
root. The browser blocks requests outside its loopback server. A local browser route injects a 404
|
|
for the generated Wownero skin sheet so its readable failed-asset state is checked consistently,
|
|
whether or not that ignored asset is present on the workstation.
|