openvibers/openvibe.games Commits

Games: the casual line's kit and Vibe Heroes, an idle hero collector at /play/vibe-heroes (plan T22) (#58)

OP OpenVibers committed 30 days ago · 134dbaba83 · 67 files, +8373 −33

Plan T22, first step (the owner's 2026-10-13 decision): cheap browser games that an X ad opens straight into on a phone, built from one kit. This adds the kit, its server economy and the first game, **Vibe Heroes**, an idle hero collector at `https://openvibe.games/play/vibe-heroes`. Reference: [docs/casual.md](docs/casual.md).

**The kit (`packages/casual`, engine-free, used by server and page)**
- A game is a data pack. Vibe Heroes has 20 original heroes, all adults, in four rarities, five elements and six classes; 26 enemies; 100 stages in 10 chapters with a boss every tenth; 13 pieces of gear; 10 quests; a 7-day login calendar; a summon banner; a gem store.
- Systems:
  - the auto-battle, fought on the server; the page plays back its log;
  - shards that fuse into heroes, then levels, star-ups (each raises the level cap) and three gear slots;
  - energy that refills over time, sweeps of three-star stages, and an idle chest that pays offline;
  - daily and weekly quests, the login calendar, a weekly featured-hero rotation, and red dots.
- Balance was checked with simulations:
  - the starting team wins stages 1–4 at level 1;
  - a first-session team (one epic, one rare) beats the first boss at level 9;
  - late stages are winnable only with 5–6★ teams.

**Server economy (`apps/server/src/casual`, migration 0007, expand)**
- One transaction per action:
  1. lock the save;
  2. if the idempotency key was applied before, answer the stored result;
  3. apply the kit's rules;
  4. write the state, one `casual_ledger` line per currency or item change, and the action record.
- A refusal answers 422 with its code and changes nothing. A stale page gets 409 `content_changed` (pack digest).
- API under `/api/v1/casual/:game`: `session`, `actions`, `leaderboard` (signed-in players only) and `ledger` (the player's own history).
- Rate limits: `games.casual.session` and `games.casual.action`.
- Identity is the Games guest token or a Network session, the same two the 3D games use. Signing in on the same browser moves the guest's save into the account.
- Account data:
  - merges move a save to the surviving account; when that account already has a save for the game, the other one is kept, not deleted;
  - export adds `casual-saves.json` and `casual-ledger.json`;
  - deletion erases saves, ledger and action records.
- Idempotency records are pruned after a day.

**Money and chance (ADR-055 stays intact)**
- Summons use sigils, which are earned in play and never sold.
- Published odds per hero, a visible guarantee (a legendary within 50 draws), and ten-at-once always holding an epic or better.
- Gems earned in play are kept apart from bought gems (`gemsPaid`); earned gems are spent first.
- The store sells only for gems, and every product lists exactly what it gives.
- Nothing is sold for money yet: paid products wait for Billing's checkout (T5, O1); paid draws wait for O37.

**The page (`/play/<game>`)**
- A DOM client with no 3D engine: 26 KB gzip of initial JS, against a new 60 KB budget in `bundleBudget.ts`. A splash paints before the script.
- Phone-first; the same column with a frame on desktop. Includes the shared navbar and footer, and a link from the Frame's menus.
- Portraits and enemies are SVG drawn from data, so nothing is downloaded.
- The server fills in the game's title, Open Graph and X card (`/casual/vibe-heroes/card.png`, drawn by `scripts/casualCard.ts` from the same art) and JSON-LD.
- `?src=&c=` (the ad campaign) is sent with the first visit, kept on the save (first-party only) and removed from the address bar.
- The game is in the sitemap, `/llms.txt`, the home page's JSON-LD and a new card on the home page.

**Tests**
- Kit (27): rules, refusals, the ledger adding up, the summon guarantees, quest resets, limits, featured products, `parseAction`.
- Server (11), over real HTTP on PGlite: sessions, idempotent replay, refusals, stale digest, adoption on sign-in, guests off the board, the page and its redirects.
- Persistence: merge, export and erase of casual saves.
- Budget, discovery and account data tests updated.
- Whole suite: 120 files, 1,256 tests pass.
- Checked by hand in a phone-size browser: first visit, battle and result, fuse, a ten-draw summon, quests, shop and menu.

**Next**
- Campaign numbers per game (landing → first battle, D1/D7) for O36.
- The consented X pixel once the owner has an ads account.
- Paid products on Billing.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ff72fc9..d36adc8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -8,6 +8,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
++- **The casual line and Vibe Heroes (plan T22).** `packages/casual` is a kit for small phone-first games: data
++ packs, a server-fought auto-battle, shards and fusing, levels and star-ups, gear, energy, an idle chest with
++ offline earnings, daily and weekly quests, a login calendar, earned-only summons with published odds and a
++ visible guarantee, and a gem store whose products list exactly what they give. Vibe Heroes (20 original
++ heroes, 100 stages in 10 chapters) plays at `/play/vibe-heroes` as a guest, and a sign-in on the same browser
++ takes the save into the account. Every currency or item change is a ledger line written in the same
++ transaction as the state, under the client's idempotency key (migration 0007). The page loads under 30 KB of
++ gzip JS, has its own share card, and keeps an ad link's `src`/`c` with the first visit.
++
- **Scraplandia "Haul" mode.** Recovery-bird extractions now score haul points:
each secured valuable scores `count × valuable.points`. Valuable item definitions
carry per-item points (`salvage_core` 20, `pepper_tonic` 30,
ddiff --git a/README.md b/README.md
iindex bf56ab6..f8d0f3d 100644
---- a/README.md
++++ b/README.md
@@@ -8,6 +8,8 @@ recovery bird earns haul points, with its own all-time/this-week leaderboard and
The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
See [docs/games.md](docs/games.md) for both games and local progression.
++It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with
++**Vibe Heroes**, an idle hero collector at `/play/vibe-heroes`. See [docs/casual.md](docs/casual.md).
A persistent multiplayer physics sandbox for the browser: Source-style movement,
physgun-driven building from crafted physical objects, gathering, crafting, and
extraction risk — running against an authoritative dedicated server.
@@@ -25,6 +27,8 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit), the
`games-quickjs@1` server script mods (docs/mods.md) and the `games.*` events
- the game WebSocket protocol and the portal, `/play` and `/editor` pages
++- the casual kit (`packages/casual`), its server economy (saves, ledger, idempotent actions) and the `/play/<game>`
++ pages
## Does not own
@@@ -54,6 +58,7 @@ packages/
gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities
physics/ PhysicsWorld abstraction + Rapier adapter (@openvibe/physics/rapier)
persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrations
++ casual/ The casual kit: rules, auto-battle and the Vibe Heroes pack (engine-free)
docs/adr/ Architecture decision records
```
ddiff --git a/STATUS.json b/STATUS.json
iindex 7e0ef2d..a582c0b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -2,7 +2,7 @@
"repository": "OpenVibers/OpenVibe.Games",
"stage": "stable",
"code": true,
-- "product": "Scraplandia and Salvage Rush",
++ "product": "Scraplandia, Salvage Rush and Vibe Heroes",
"runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
@@@ -27,6 +27,7 @@
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"M4 in working tree (not deployed): one-process rooms, PostgreSQL instance registry and fenced leases, capacity-based join, same-place transfer, per-room position, grid-cell interest and socketless load probe",
"M5 first part in working tree (not deployed): one Games process hosts Scraplandia and Salvage Rush in separate place and instance registries; server-scored rounds, per-game achievements and paginated all-time/week leaderboards in Games",
++ "the casual line (plan T22): packages/casual kit, server economy with ledger and idempotent actions, Vibe Heroes at /play/vibe-heroes (guest play, adoption on sign-in, earned-only summons, gem store; paid products wait for Billing)",
"six achievement rewards granted as free OpenVibe.Inventory items, never sold or traded; GAMES_ITEMS=on/off and GAMES_INVENTORY_URL configure delivery",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
@@@ -37,5 +38,5 @@
"the OpenVibe Frame from its own pinned copy",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits)"
],
-- "updated": "2026-10-11"
++ "updated": "2026-10-14"
}
ddiff --git a/apps/client/casual.html b/apps/client/casual.html
nnew file mode 100644
iindex 0000000..717eed0
---- /dev/null
++++ b/apps/client/casual.html
@@@ -0,0 +1,95 @@
++<!doctype html>
++<html lang="en">
++ <head>
++ <meta charset="utf-8" />
++ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
++ <meta name="theme-color" content="#120c2a" />
++ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
++ <!--casual:head-->
++ <meta name="robots" content="index, follow" />
++ <!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
++ <script src="/shared/theme-loader.js"></script>
++ <script src="/shared/navbar.js" defer></script>
++ <script src="/shared/footer.js" defer></script>
++ <script>
++ window.__ovgFrame = { page: 'casual', title: 'Vibe Heroes' }
++ </script>
++ <script src="/frame.js" defer></script>
++ <style>
++ /* The splash paints before the game's script arrives (an ad click lands here first). */
++ html,
++ body {
++ margin: 0;
++ background: #0b0820;
++ }
++ #navbar-mount {
++ min-height: 56px;
++ }
++ .cg {
++ max-width: 480px;
++ margin: 0 auto;
++ height: calc(100dvh - 56px);
++ min-height: 520px;
++ display: flex;
++ flex-direction: column;
++ color: #f4f1ff;
++ background:
++ radial-gradient(120% 60% at 50% 0%, #3a2378 0%, transparent 60%),
++ radial-gradient(90% 50% at 50% 100%, #1d3a7a 0%, transparent 70%), #120c2a;
++ font:
++ 15px/1.4 system-ui,
++ -apple-system,
++ 'Segoe UI',
++ Roboto,
++ sans-serif;
++ }
++ .splash {
++ margin: auto;
++ text-align: center;
++ padding: 24px;
++ }
++ .splash-logo {
++ font-size: 40px;
++ font-weight: 900;
++ letter-spacing: 0.02em;
++ background: linear-gradient(180deg, #fff6c8, #ffd25a 45%, #ff9f1c);
++ -webkit-background-clip: text;
++ background-clip: text;
++ color: transparent;
++ filter: drop-shadow(0 3px 0 #5a2a00);
++ }
++ .splash-ring {
++ width: 42px;
++ height: 42px;
++ margin: 22px auto 10px;
++ border-radius: 50%;
++ border: 4px solid rgba(255, 255, 255, 0.15);
++ border-top-color: #ffd25a;
++ animation: spin 0.9s linear infinite;
++ }
++ @keyframes spin {
++ to {
++ transform: rotate(360deg);
++ }
++ }
++ @media (prefers-reduced-motion: reduce) {
++ .splash-ring {
++ animation: none;
++ }
++ }
++ </style>
++ </head>
++ <body>
++ <div id="navbar-mount"></div>
++ <main id="cg" class="cg">
++ <div class="splash">
++ <div class="splash-logo">Vibe Heroes</div>
++ <div class="splash-ring" aria-hidden="true"></div>
++ <p>Summoning your heroes…</p>
++ <noscript><p>Vibe Heroes needs JavaScript to play.</p></noscript>
++ </div>
++ </main>
++ <footer id="ov-footer" class="ovf"></footer>
++ <script type="module" src="/src/casual/main.ts"></script>
++ </body>
++</html>
ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 236e350..ab5fe13 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -7,7 +7,7 @@
<title>OpenVibe.Games — browser games on the OpenVibe network</title>
<meta
name="description"
-- content="The games arm of the OpenVibe network. Play Scraplandia, multiplayer survival-building in Scrap City, or Salvage Rush, 90-second scrap-collecting rounds with a leaderboard, in your browser with no download."
++ content="The games arm of the OpenVibe network. Play Scraplandia, multiplayer survival-building in Scrap City; Salvage Rush, 90-second scrap-collecting rounds with a leaderboard; or Vibe Heroes, an idle hero collector for your phone. All in your browser with no download."
/>
<!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
<!-- Deferred: a slow or unreachable openvibe.network never holds the first paint (the inline colours above cover it). -->
@@@ -21,7 +21,7 @@
<meta property="og:title" content="OpenVibe.Games — browser games on the OpenVibe network" />
<meta
property="og:description"
-- content="Play Scraplandia or Salvage Rush in your browser, no download: survival-building in Scrap City, or timed scrap-collecting rounds with a leaderboard. One OpenVibe account across the whole network."
++ content="Play Scraplandia, Salvage Rush or Vibe Heroes in your browser, no download: survival-building in Scrap City, timed scrap-collecting rounds, or an idle hero collector for your phone. One OpenVibe account across the whole network."
/>
<meta property="og:url" content="https://openvibe.games/" />
<meta property="og:image" content="https://openvibe.games/og-image.png" />
@@@ -204,6 +204,9 @@
height: 100%;
display: block;
}
++ .shot .shot-img {
++ object-fit: contain;
++ }
.game-body {
padding: 26px;
display: flex;
@@@ -664,6 +667,38 @@
</div>
</div>
</div>
++ <div class="game-card wide">
++ <div
++ class="shot"
++ role="img"
++ aria-label="Vibe Heroes: five hero cards fanned out, a legendary in the middle"
++ >
++ <img
++ class="shot-art shot-img"
++ src="/casual/vibe-heroes/heroes.webp"
++ width="400"
++ height="394"
++ loading="lazy"
++ decoding="async"
++ alt=""
++ />
++ </div>
++ <div class="game-body">
++ <span class="badge live">● NEW</span>
++ <h3>VIBE HEROES</h3>
++ <p>
++ An idle hero collector made for your phone. Your team battles on its own: fuse heroes
++ from shards, star them up and push through 100 stages, even while you are away. Summons
++ use sigils you earn in play, so nothing is a paid random draw.
++ </p>
++ <div class="tags">
++ <span>idle</span><span>hero collector</span><span>phone-first</span>
++ </div>
++ <div class="cta-row">
++ <a class="play-cta" href="/play/vibe-heroes">▶ PLAY NOW</a>
++ </div>
++ </div>
++ </div>
</section>
<section class="account">
ddiff --git a/apps/client/package.json b/apps/client/package.json
iindex 3e986f4..dd56540 100644
---- a/apps/client/package.json
++++ b/apps/client/package.json
@@@ -14,6 +14,7 @@
"@babylonjs/loaders": "^9.20.0",
"@babylonjs/materials": "^9.20.0",
"@babylonjs/procedural-textures": "^9.20.0",
++ "@openvibe/casual": "workspace:*",
"@openvibe/content": "workspace:*",
"@openvibe/gameplay": "workspace:*",
"@openvibe/physics": "workspace:*",
ddiff --git a/apps/client/public/casual/vibe-heroes/card.png b/apps/client/public/casual/vibe-heroes/card.png
nnew file mode 100644
iindex 0000000..8a35dd7
BBinary files /dev/null and b/apps/client/public/casual/vibe-heroes/card.png differ
ddiff --git a/apps/client/public/casual/vibe-heroes/heroes.webp b/apps/client/public/casual/vibe-heroes/heroes.webp
nnew file mode 100644
iindex 0000000..d45e4db
BBinary files /dev/null and b/apps/client/public/casual/vibe-heroes/heroes.webp differ
ddiff --git a/apps/client/public/frame.js b/apps/client/public/frame.js
iindex 5d0acf8..16e8f2b 100644
---- a/apps/client/public/frame.js
++++ b/apps/client/public/frame.js
@@@ -8,8 +8,8 @@
*
* Pages configure it with `window.__ovgFrame = { page, title, collapsible }`
* before this script runs:
-- * page 'portal' | 'play' | 'editor'
-- * title recorded to the signed-in user's network history (play only)
++ * page 'portal' | 'play' | 'editor' | 'casual'
++ * title recorded to the signed-in user's network history (play and casual)
* collapsible the Frame folds away once the game starts (the `ovg:playing`
* event) and a small floating button brings it back.
* toggleFromStart show that button immediately (the editor), not only after
@@@ -30,6 +30,8 @@
? '/?game=salvage-rush'
: 'https://play.openvibe.games/?game=salvage-rush'
var BOARD = local || !onPlayHost ? '/leaderboard.html' : 'https://openvibe.games/leaderboard.html'
++ var HEROES =
++ local || !onPlayHost ? '/play/vibe-heroes' : 'https://openvibe.games/play/vibe-heroes'
function cookie(name) {
var m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'))
@@@ -55,6 +57,7 @@
{ label: 'Games', href: PORTAL },
{ label: 'Scraplandia', href: PLAY },
{ label: 'Salvage Rush', href: RUSH },
++ { label: 'Vibe Heroes', href: HEROES },
{ label: 'Leaderboard', href: BOARD },
]
// The shared footer's items are { name, url } (label/href rendered empty links).
@@@ -65,6 +68,7 @@
{ name: 'All games', url: PORTAL },
{ name: 'Play Scraplandia', url: PLAY },
{ name: 'Play Salvage Rush', url: RUSH },
++ { name: 'Play Vibe Heroes', url: HEROES },
{ name: 'Leaderboard', url: BOARD },
{ name: 'Map editor', url: EDITOR },
],
@@@ -96,7 +100,8 @@
// One prompt=none attempt per tab when the browser says it has an OpenVibe account.
silentLogin: '/auth/login?silent=1&next={url}',
}
-- if (cfg.page === 'play') opts.history = { type: 'game', title: cfg.title || 'Scraplandia' }
++ if (cfg.page === 'play' || cfg.page === 'casual')
++ opts.history = { type: 'game', title: cfg.title || 'Scraplandia' }
window.OpenVibeNavbar.init(opts)
}
ddiff --git a/apps/client/scripts/bundleBudget.test.ts b/apps/client/scripts/bundleBudget.test.ts
iindex b15ab71..b0a8a1a 100644
---- a/apps/client/scripts/bundleBudget.test.ts
++++ b/apps/client/scripts/bundleBudget.test.ts
@@@ -5,11 +5,13 @@
* measured by the CI step, not here.
*/
import { randomBytes } from 'node:crypto'
++import { randomBytes } from 'node:crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
++ CASUAL_INITIAL_GZIP_LIMIT_BYTES,
CHUNK_LIMIT_BYTES,
INITIAL_JS_LIMIT_BYTES,
assessBundleBudget,
@@@ -53,6 +55,30 @@ describe('bundle budget', () => {
expect(exitCodeFor(report, 'report')).toBe(1)
})
++ it('holds the casual page to its own small budget (plan T22)', () => {
++ const ok = assessBundleBudget(
++ makeDist({
++ 'play.html': page('/assets/main.js'),
++ 'assets/main.js': filler(1_000),
++ 'casual.html': page('/assets/casual.js'),
++ 'assets/casual.js': filler(5_000),
++ }),
++ )
++ expect(ok.violations).toEqual([])
++ expect(formatReport(ok)).toContain('casual entry')
++ const heavy = assessBundleBudget(
++ makeDist({
++ 'play.html': page('/assets/main.js'),
++ 'assets/main.js': filler(1_000),
++ 'casual.html': page('/assets/casual.js'),
++ // Random bytes do not compress, so the gzip size is the raw size.
++ 'assets/casual.js': randomBytes(CASUAL_INITIAL_GZIP_LIMIT_BYTES * 2),
++ }),
++ )
++ expect(heavy.violations.map((v) => v.message).join()).toContain('casual entry')
++ expect(exitCodeFor(heavy, 'report')).toBe(1)
++ })
++
it('passes within budget, and exempts a chunk only the editor entry loads', () => {
const dist = makeDist({
'play.html': page('/assets/main.js', ['/assets/vendor.js', '/assets/lazy.js']),
ddiff --git a/apps/client/scripts/bundleBudget.ts b/apps/client/scripts/bundleBudget.ts
iindex abb4ad2..1615df5 100644
---- a/apps/client/scripts/bundleBudget.ts
++++ b/apps/client/scripts/bundleBudget.ts
@@@ -28,6 +28,11 @@ import { gzipSync } from 'node:zlib'
export const INITIAL_JS_LIMIT_BYTES = 2_500_000
/** 400 KB, decimal units. */
export const CHUNK_LIMIT_BYTES = 400_000
++/**
++ * The casual games' page (plan T22) opens from an ad on a phone, often in an in-app browser on 4G, and must be
++ * playable in about three seconds: its initial JS stays under 60 KB gzip.
++ */
++export const CASUAL_INITIAL_GZIP_LIMIT_BYTES = 60_000
const MODULE_SCRIPT = /<script[^>]*\btype=["']module["'][^>]*>/gi
const MODULEPRELOAD = /<link[^>]*\brel=["']modulepreload["'][^>]*>/gi
@@@ -53,6 +58,8 @@ export interface BudgetViolation {
export interface BudgetReport {
dist: string
initial: { files: FileMeasurement[]; rawBytes: number; gzipBytes: number }
++ /** The casual page's initial JS, when the build has one. */
++ casual?: { files: FileMeasurement[]; rawBytes: number; gzipBytes: number }
/** Every JS chunk measured against 400 KB; editor-only chunks are excluded. */
chunks: FileMeasurement[]
violations: BudgetViolation[]
@@@ -175,6 +182,20 @@ export function assessBundleBudget(distDir: string): BudgetReport {
})
}
++ if (existsSync(join(dist, 'casual.html'))) {
++ const files = pageInitial(dist, 'casual.html').map((file) => measure(dist, file))
++ report.casual = { files, rawBytes: sum(files, 'rawBytes'), gzipBytes: sum(files, 'gzipBytes') }
++ if (report.casual.gzipBytes > CASUAL_INITIAL_GZIP_LIMIT_BYTES) {
++ violations.push({
++ scope: 'initial',
++ metric: 'gzip',
++ actualBytes: report.casual.gzipBytes,
++ limitBytes: CASUAL_INITIAL_GZIP_LIMIT_BYTES,
++ message: `initial JS (casual entry) is ${human(report.casual.gzipBytes)} gzip, over the ${human(CASUAL_INITIAL_GZIP_LIMIT_BYTES)} limit`,
++ })
++ }
++ }
++
for (const chunk of report.chunks) {
if (chunk.rawBytes > CHUNK_LIMIT_BYTES) {
violations.push({
@@@ -209,6 +230,13 @@ export function formatReport(report: BudgetReport): string {
`${human(report.initial.rawBytes)} raw, ${human(report.initial.gzipBytes)} gzip ` +
`— limit ${human(INITIAL_JS_LIMIT_BYTES)}`,
)
++ if (report.casual) {
++ lines.push(
++ ` initial JS (casual entry, ${report.casual.files.length} files): ` +
++ `${human(report.casual.rawBytes)} raw, ${human(report.casual.gzipBytes)} gzip ` +
++ `— limit ${human(CASUAL_INITIAL_GZIP_LIMIT_BYTES)} gzip`,
++ )
++ }
const largest = report.chunks.reduce<FileMeasurement | undefined>(
(max, chunk) => (!max || chunk.rawBytes > max.rawBytes ? chunk : max),
undefined,
ddiff --git a/apps/client/scripts/casualCard.ts b/apps/client/scripts/casualCard.ts
nnew file mode 100644
iindex 0000000..e65e148
---- /dev/null
++++ b/apps/client/scripts/casualCard.ts
@@@ -0,0 +1,91 @@
++/**
++ * Draws a casual game's share card (plan T22: the Open Graph / X card a shared or advertised link unfurls as),
++ * 1200×630, from the game's own heroes, into public/casual/<game>/card.png.
++ *
++ * Run: node --import tsx apps/client/scripts/casualCard.ts [game] (needs Chrome: playwright's, or CHROME=path)
++ * The PNG is committed; rerun it when the game's look or name changes.
++ */
++import { mkdirSync } from 'node:fs'
++import { dirname, join } from 'node:path'
++import { fileURLToPath } from 'node:url'
++import { chromium } from 'playwright'
++import { CASUAL_GAMES, heroDef } from '@openvibe/casual'
++
++const root = join(dirname(fileURLToPath(import.meta.url)), '..')
++const game = process.argv[2] ?? 'vibe-heroes'
++const pack = CASUAL_GAMES[game]
++if (!pack) throw new Error(`unknown casual game ${game}`)
++
++// The portraits as markup (no DOM needed), the same art the game draws.
++const { portraitMarkup } = await import('../src/casual/art.js')
++
++const RING: Record<string, string> = {
++ legendary: '#ffcf3f',
++ epic: '#b979ff',
++ rare: '#45a8ff',
++ common: '#a3adbb',
++}
++const BG: Record<string, string> = {
++ flame: 'radial-gradient(circle at 50% 30%, #ffb38a, #e2502a 75%)',
++ tide: 'radial-gradient(circle at 50% 30%, #a6dcff, #2a7fd8 75%)',
++ grove: 'radial-gradient(circle at 50% 30%, #b8f0b0, #2f9a4c 75%)',
++ dawn: 'radial-gradient(circle at 50% 30%, #fff1b0, #e0a400 75%)',
++ dusk: 'radial-gradient(circle at 50% 30%, #d6c2ff, #6a3fd0 75%)',
++}
++const cast = ['kaito', 'thalia', 'seraphine', 'nyx', 'juno']
++// Fanned: left, top and tilt of each card; the middle one is the big legendary.
++const FAN = [
++ [0, 170, -12],
++ [95, 115, -6],
++ [175, 60, 0],
++ [315, 115, 6],
++ [405, 170, 12],
++]
++const cards = cast
++ .map((id, i) => {
++ const d = heroDef(pack, id)
++ if (!d) return ''
++ const [left, top, tilt] = FAN[i] ?? [0, 0, 0]
++ const mid = i === 2
++ return `<div class="card${mid ? ' mid' : ''}" style="--rc:${RING[d.rarity]};background:${BG[d.element]};left:${left}px;top:${top}px;transform:rotate(${tilt}deg);z-index:${mid ? 5 : 3 - Math.abs(i - 2)}">${portraitMarkup(d)}</div>`
++ })
++ .join('')
++
++const html = `<!doctype html><html><head><meta charset="utf-8"><style>
++ body { margin: 0; width: 1200px; height: 630px; overflow: hidden; font-family: 'Noto Sans', system-ui, sans-serif;
++ background: radial-gradient(700px 420px at 78% 40%, rgba(255,207,63,.35), transparent 70%),
++ radial-gradient(900px 600px at 10% 0%, #3a2378, transparent 70%), radial-gradient(900px 500px at 50% 120%, #1d3a7a, transparent 70%), #120c2a; color: #f4f1ff; }
++ .text { position: absolute; left: 64px; top: 92px; width: 500px; }
++ .eyebrow { font-size: 22px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: #bdb4dc; }
++ h1 { margin: 10px 0 18px; font-size: 100px; line-height: .95; font-weight: 900;
++ background: linear-gradient(180deg, #fff6c8, #ffd25a 45%, #ff9f1c); -webkit-background-clip: text; background-clip: text; color: transparent;
++ filter: drop-shadow(0 5px 0 #5a2a00); }
++ p { margin: 0; font-size: 28px; line-height: 1.3; color: #e9e4ff; }
++ .pill { display: inline-block; margin-top: 30px; padding: 14px 26px; border-radius: 999px; font-size: 26px; font-weight: 900; color: #2a1600;
++ background: linear-gradient(180deg, #fff1a8, #ffd25a 40%, #ff9f1c); box-shadow: 0 5px 0 #8a4b00; }
++ .row { position: absolute; left: 585px; top: 40px; width: 580px; height: 560px; }
++ .card { position: absolute; width: 170px; height: 230px; border-radius: 20px; overflow: hidden; border: 5px solid var(--rc);
++ box-shadow: 0 20px 40px rgba(0,0,0,.55), 0 0 26px color-mix(in srgb, var(--rc) 50%, transparent); }
++ .card.mid { width: 230px; height: 310px; }
++ .card svg { width: 100%; height: 100%; display: block; }
++ .brand { position: absolute; left: 64px; bottom: 34px; font-size: 22px; font-weight: 800; color: #bdb4dc; letter-spacing: .02em; }
++</style></head><body>
++ <div class="text"><div class="eyebrow">Free in your browser</div><h1>${pack.name}</h1>
++ <p>Collect heroes, fuse their shards and win even while you are away.</p><div class="pill">Play now, no download</div></div>
++ <div class="row">${cards}</div>
++ <div class="brand">openvibe.games</div>
++</body></html>`
++
++const out = join(root, 'public', 'casual', pack.id, 'card.png')
++mkdirSync(dirname(out), { recursive: true })
++const browser = await chromium.launch(
++ process.env.CHROME ? { executablePath: process.env.CHROME } : {},
++)
++try {
++ const page = await browser.newPage({ viewport: { width: 1200, height: 630 } })
++ await page.setContent(html)
++ await page.screenshot({ path: out })
++ console.log(`card: ${out}`)
++} finally {
++ await browser.close()
++}
ddiff --git a/apps/client/src/casual/api.ts b/apps/client/src/casual/api.ts
nnew file mode 100644
iindex 0000000..5939c23
---- /dev/null
++++ b/apps/client/src/casual/api.ts
@@@ -0,0 +1,138 @@
++/**
++ * The casual API from the page. Actions go one at a time, each under a fresh idempotency key that a retry reuses,
++ * so a dropped answer on a phone network never applies a tap twice.
++ */
++import type { Action, PlayerState } from '@openvibe/casual'
++
++export interface Player {
++ signedIn: boolean
++ name: string | null
++}
++
++export interface SessionAnswer {
++ state: PlayerState
++ version: number
++ created: boolean
++ now: number
++ digest: string
++ player: Player
++}
++
++export interface ActionAnswer {
++ state: PlayerState
++ version: number
++ result: Record<string, unknown>
++ now: number
++}
++
++export class ApiError extends Error {
++ constructor(
++ readonly status: number,
++ readonly code: string,
++ message: string,
++ ) {
++ super(message)
++ }
++}
++
++const wait = (ms: number) => new Promise((r) => window.setTimeout(r, ms))
++
++export class CasualApi {
++ /** Server clock minus this device's clock, so countdowns match the server's resets. */
++ offset = 0
++ private queue: Promise<unknown> = Promise.resolve()
++
++ constructor(
++ readonly game: string,
++ readonly digest: string,
++ private identity: { guest: string; bearer: string | undefined },
++ ) {}
++
++ now(): number {
++ return Date.now() + this.offset
++ }
++
++ dropBearer(): void {
++ this.identity.bearer = undefined
++ }
++
++ private headers(json: boolean): Record<string, string> {
++ const h: Record<string, string> = { 'x-guest-token': this.identity.guest }
++ if (this.identity.bearer) h.authorization = `Bearer ${this.identity.bearer}`
++ if (json) {
++ h['content-type'] = 'application/json'
++ h['x-casual-digest'] = this.digest
++ }
++ return h
++ }
++
++ private async request<T>(path: string, body?: unknown): Promise<T> {
++ let lastError: unknown = null
++ for (let attempt = 0; attempt < 3; attempt++) {
++ if (attempt) await wait(attempt * 900)
++ let r: Response
++ try {
++ r = await fetch(`/api/v1/casual/${this.game}/${path}`, {
++ method: body === undefined ? 'GET' : 'POST',
++ headers: this.headers(body !== undefined),
++ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
++ credentials: 'same-origin',
++ })
++ } catch (err) {
++ lastError = err
++ continue
++ }
++ const data = (await r.json().catch(() => ({}))) as Record<string, unknown>
++ if (r.ok) return data as T
++ if (r.status === 503 && attempt < 2) {
++ lastError = new ApiError(503, 'unavailable', String(data.message ?? 'Busy'))
++ continue
++ }
++ throw new ApiError(
++ r.status,
++ String(data.error ?? 'error'),
++ String(data.message ?? 'Something went wrong. Try again.'),
++ )
++ }
++ throw lastError instanceof ApiError
++ ? lastError
++ : new ApiError(0, 'offline', 'No connection. Check your signal and try again.')
++ }
++
++ async session(src: string | null, campaign: string | null): Promise<SessionAnswer> {
++ const before = Date.now()
++ const answer = await this.request<SessionAnswer>('session', {
++ ...(src ? { src } : {}),
++ ...(campaign ? { c: campaign } : {}),
++ })
++ this.offset = answer.now - Math.round((before + Date.now()) / 2)
++ return answer
++ }
++
++ act(action: Action): Promise<ActionAnswer> {
++ const key = crypto.randomUUID()
++ const run = this.queue.then(() => this.request<ActionAnswer>('actions', { key, action }))
++ this.queue = run.catch(() => undefined)
++ return run
++ }
++
++ leaderboard(): Promise<{
++ entries: { rank: number; name: string; stage: number; power: number }[]
++ }> {
++ return this.request('leaderboard')
++ }
++
++ ledger(before: number | null): Promise<{
++ lines: {
++ id: number
++ asset: string
++ delta: number
++ balance: number
++ reason: string
++ at: number
++ }[]
++ next: number | null
++ }> {
++ return this.request(`ledger${before ? `?before=${before}` : ''}`)
++ }
++}
ddiff --git a/apps/client/src/casual/app.ts b/apps/client/src/casual/app.ts
nnew file mode 100644
iindex 0000000..4456e4e
---- /dev/null
++++ b/apps/client/src/casual/app.ts
@@@ -0,0 +1,181 @@
++/**
++ * The casual page's shell: the player's state as the server last answered it, the top bar with the wallet and
++ * energy, the five tabs, and the one way to change anything (`run`, which asks the server).
++ */
++import {
++ energyNow,
++ gemsTotal,
++ nextEnergyAt,
++ notices,
++ type Action,
++ type CasualPack,
++ type PlayerState,
++} from '@openvibe/casual'
++import { ApiError, type ActionAnswer, type CasualApi, type Player } from './api.js'
++import { icon } from './art.js'
++import { button, duration, h, num, sheet, tick, toast } from './dom.js'
++import { forgetSession } from '../net/identity.js'
++
++export type Tab = 'home' | 'heroes' | 'summon' | 'quests' | 'shop'
++
++export type View = (app: App) => HTMLElement
++
++const TABS: { id: Tab; label: string; icon: string }[] = [
++ { id: 'home', label: 'Battle', icon: 'battle' },
++ { id: 'heroes', label: 'Heroes', icon: 'heroes' },
++ { id: 'summon', label: 'Summon', icon: 'summon' },
++ { id: 'quests', label: 'Quests', icon: 'quests' },
++ { id: 'shop', label: 'Shop', icon: 'shop' },
++]
++
++export class App {
++ tab: Tab = 'home'
++ busy = false
++ private top = h('header', { class: 'cg-top' })
++ private view = h('section', { class: 'cg-view', attrs: { 'aria-live': 'off' } })
++ private tabs = h('nav', { class: 'cg-tabs', attrs: { 'aria-label': 'Game sections' } })
++ private menu: (app: App) => void = () => {}
++
++ constructor(
++ readonly root: HTMLElement,
++ readonly pack: CasualPack,
++ readonly api: CasualApi,
++ public state: PlayerState,
++ public version: number,
++ public player: Player,
++ private views: Record<Tab, View>,
++ ) {
++ root.replaceChildren(this.top, this.view, this.tabs)
++ }
++
++ onMenu(fn: (app: App) => void): void {
++ this.menu = fn
++ }
++
++ now(): number {
++ return this.api.now()
++ }
++
++ /** Asks the server to apply an action; the answer replaces the state. Refusals become a toast. */
++ async run(action: Action, opts: { quiet?: boolean } = {}): Promise<ActionAnswer | null> {
++ if (this.busy) return null
++ this.busy = true
++ this.root.classList.add('is-busy')
++ try {
++ const answer = await this.api.act(action)
++ if (answer.version >= this.version) {
++ this.state = answer.state
++ this.version = answer.version
++ }
++ if (!opts.quiet) this.render()
++ return answer
++ } catch (err) {
++ this.fail(err)
++ return null
++ } finally {
++ this.busy = false
++ this.root.classList.remove('is-busy')
++ }
++ }
++
++ fail(err: unknown): void {
++ if (err instanceof ApiError && err.code === 'content_changed') {
++ const s = sheet('A new version is out')
++ s.body.append(
++ h('p', {
++ text: 'Vibe Heroes was just updated. Reload to keep playing; your progress is saved.',
++ }),
++ button('Reload', () => location.reload(), 'btn btn-gold btn-wide'),
++ )
++ return
++ }
++ if (err instanceof ApiError && err.code === 'session_expired') {
++ forgetSession()
++ this.api.dropBearer()
++ toast('Your sign-in expired. Sign in again from the top bar to keep this progress.', 'bad')
++ return
++ }
++ toast(err instanceof Error ? err.message : 'Something went wrong.', 'bad')
++ }
++
++ go(tab: Tab): void {
++ this.tab = tab
++ this.render()
++ this.view.scrollTop = 0
++ }
++
++ render(): void {
++ this.renderTop()
++ this.view.replaceChildren(this.views[this.tab](this))
++ this.view.dataset.tab = this.tab
++ this.renderTabs()
++ }
++
++ private renderTop(): void {
++ const s = this.state
++ const p = this.pack
++ const chip = (name: string, value: Node | string, label: string, cls = '') =>
++ h(
++ 'div',
++ { class: `chip ${cls}`, attrs: { 'aria-label': label }, title: label },
++ icon(name),
++ h('b', null, value),
++ )
++ const energy = h('b')
++ tick(energy, () => `${energyNow(p, this.state, this.now())}/${p.energy.max}`)
++ const energyTimer = h('small')
++ tick(energyTimer, () => {
++ const at = nextEnergyAt(p, this.state, this.now())
++ return at ? duration(at - this.now()) : ''
++ })
++ this.top.replaceChildren(
++ chip('gold', num(s.wallet.gold), `${s.wallet.gold.toLocaleString('en-US')} gold`),
++ chip(
++ 'gems',
++ num(gemsTotal(s)),
++ s.wallet.gemsPaid
++ ? `${gemsTotal(s)} gems (${s.wallet.gems} earned, ${s.wallet.gemsPaid} bought)`
++ : `${s.wallet.gems} gems`,
++ ),
++ chip('sigils', String(s.wallet.sigils), `${s.wallet.sigils} summon sigils`),
++ h(
++ 'div',
++ {
++ class: 'chip chip-energy',
++ attrs: { 'aria-label': 'Energy' },
++ title: 'Energy: one point every five minutes',
++ },
++ icon('energy'),
++ h('span', { class: 'chip-stack' }, energy, energyTimer),
++ ),
++ button(icon('menu'), () => this.menu(this), 'icon-btn top-menu', { 'aria-label': 'Menu' }),
++ )
++ }
++
++ private renderTabs(): void {
++ const n = notices(this.pack, this.state, this.now())
++ const dots: Record<Tab, boolean> = {
++ home: n.idle || n.login,
++ heroes: n.heroes > 0,
++ summon: n.summon,
++ quests: n.quests > 0 || n.login,
++ shop: false,
++ }
++ this.tabs.replaceChildren(
++ ...TABS.map((t) =>
++ button(
++ [
++ icon(t.icon),
++ h('span', { text: t.label }),
++ dots[t.id]
++ ? h('i', { class: 'dot', attrs: { 'aria-label': 'Something is ready' } })
++ : null,
++ ],
++ () => this.go(t.id),
++ `tab${this.tab === t.id ? ' is-on' : ''}`,
++ this.tab === t.id ? { 'aria-current': 'page' } : {},
++ ),
++ ),
++ )
++ }
++}
ddiff --git a/apps/client/src/casual/art.ts b/apps/client/src/casual/art.ts
nnew file mode 100644
iindex 0000000..fb783b4
---- /dev/null
++++ b/apps/client/src/casual/art.ts
@@@ -0,0 +1,310 @@
++/**
++ * The casual games' art, drawn as SVG from data: hero busts from `look`, enemies by kind and element, and the UI
++ * icons. Nothing is downloaded, so the page is playable as soon as its one script has loaded.
++ */
++import type { Element, EnemyDef, HeroClass, HeroDef, Look, Rarity } from '@openvibe/casual'
++
++const NS = 'http://www.w3.org/2000/svg'
++
++export const ELEMENT_COLOR: Record<Element, string> = {
++ flame: '#ff6b3d',
++ tide: '#3ba7ff',
++ grove: '#4cc96a',
++ dawn: '#ffc93c',
++ dusk: '#a77bff',
++}
++
++export const ELEMENT_NAME: Record<Element, string> = {
++ flame: 'Flame',
++ tide: 'Tide',
++ grove: 'Grove',
++ dawn: 'Dawn',
++ dusk: 'Dusk',
++}
++
++export const CLASS_NAME: Record<HeroClass, string> = {
++ guardian: 'Guardian',
++ blade: 'Blade',
++ arcanist: 'Arcanist',
++ ranger: 'Ranger',
++ mender: 'Mender',
++ herald: 'Herald',
++}
++
++export const RARITY_NAME: Record<Rarity, string> = {
++ common: 'Common',
++ rare: 'Rare',
++ epic: 'Epic',
++ legendary: 'Legendary',
++}
++
++/** Builds an SVG element from markup this module writes itself (never from user text). */
++function svg(viewBox: string, body: string, cls = ''): SVGSVGElement {
++ const el = document.createElementNS(NS, 'svg')
++ el.setAttribute('viewBox', viewBox)
++ el.setAttribute('aria-hidden', 'true')
++ if (cls) el.setAttribute('class', cls)
++ el.innerHTML = body
++ return el
++}
++
++/** A colour darkened (negative) or lightened (positive) by `amount` of 255. */
++export function shade(hex: string, amount: number): string {
++ const n = Number.parseInt(hex.slice(1), 16)
++ const c = (v: number) => Math.max(0, Math.min(255, v + amount))
++ const r = c(n >> 16)
++ const g = c((n >> 8) & 0xff)
++ const b = c(n & 0xff)
++ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
++}
++
++let uid = 0
++
++// Hair drawn behind the head, by style.
++const BACK_HAIR = [
++ // 0 long and straight
++ 'M24 52 C20 22 80 22 76 52 L80 104 L64 98 L62 72 L38 72 L36 98 L20 104 Z',
++ // 1 ponytail
++ 'M27 50 C25 26 75 26 73 50 L72 60 L28 60 Z M66 36 C92 40 94 84 78 108 C80 86 76 64 64 50 Z',
++ // 2 bob
++ 'M26 54 C22 24 78 24 74 54 L75 76 C71 79 67 78 64 73 L36 73 C33 78 29 79 25 76 Z',
++ // 3 spiky
++ 'M28 50 C26 28 74 28 72 50 L70 62 L30 62 Z',
++ // 4 swept, collar length
++ 'M27 52 C24 26 76 26 73 52 L74 74 L66 70 L34 70 L26 74 Z',
++ // 5 curly and full
++ 'M22 60 C12 46 22 22 38 20 C46 12 60 14 66 22 C82 22 90 44 78 62 C86 74 76 92 66 86 L34 86 C22 92 14 74 22 60 Z',
++ // 6 cropped
++ 'M30 48 C30 30 70 30 70 48 L70 54 L30 54 Z',
++ // 7 bun
++ 'M28 50 C26 28 74 28 72 50 L72 60 L28 60 Z',
++]
++
++// Hair over the forehead, by style.
++const FRONT_HAIR = [
++ 'M29 52 C28 30 72 28 71 52 C66 42 60 40 54 44 C50 38 42 38 38 44 C34 42 31 46 29 52 Z',
++ 'M29 50 C30 30 70 28 71 48 C60 46 50 40 44 32 C42 40 36 46 29 50 Z',
++ 'M28 52 C28 28 72 28 72 52 L66 46 L60 50 L54 45 L48 50 L42 45 L36 50 L32 46 Z',
++ 'M28 52 L24 30 L36 38 L36 22 L46 34 L52 16 L56 34 L66 22 L64 38 L76 32 L72 52 C66 44 34 44 28 52 Z',
++ 'M29 50 C27 30 73 28 71 48 C64 40 52 36 42 40 C38 42 33 45 29 50 Z',
++ 'M28 50 C24 34 36 26 44 30 C48 22 58 24 60 30 C68 26 78 36 72 50 C66 42 58 42 50 44 C42 42 34 42 28 50 Z',
++ 'M31 46 C32 32 68 32 69 46 C62 41 38 41 31 46 Z',
++ 'M29 50 C30 32 70 30 71 50 C66 44 58 40 50 42 C42 40 34 44 29 50 Z',
++]
++
++function accessory(kind: number, look: Look, id: string): string {
++ const trim = look.trim
++ switch (kind) {
++ case 1: // circlet with a gem
++ return `<path d="M30 40 Q50 32 70 40" fill="none" stroke="${trim}" stroke-width="2.6" stroke-linecap="round"/>
++ <path d="M50 30 L54 36 L50 41 L46 36 Z" fill="${look.eyes}" stroke="${shade(trim, -40)}" stroke-width="0.8"/>`
++ case 2: // flower pin
++ return `<g transform="translate(68 40)">${[0, 72, 144, 216, 288]
++ .map(
++ (a) =>
++ `<ellipse rx="3.4" ry="5.6" fill="${shade(trim, 30)}" transform="rotate(${a}) translate(0 -4.6)"/>`,
++ )
++ .join('')}<circle r="2.6" fill="#ffe08a"/></g>`
++ case 3: // headband with a sun gem
++ return `<path d="M29 44 Q50 36 71 44" fill="none" stroke="${trim}" stroke-width="3.2"/>
++ <circle cx="50" cy="38.5" r="3.6" fill="#fff4c2" stroke="${shade(trim, -30)}" stroke-width="1"/>`
++ case 4: // hood
++ return `<path d="M20 70 C16 30 40 14 50 14 C60 14 84 30 80 70 L74 68 C76 40 64 26 50 26 C36 26 24 40 26 68 Z"
++ fill="${shade(look.outfit, 18)}" stroke="${shade(look.outfit, -30)}" stroke-width="1"/>`
++ case 5: // helm
++ return `<path d="M27 50 C26 26 74 26 73 50 L69 50 C68 34 60 30 50 30 C40 30 32 34 31 50 Z"
++ fill="url(#metal-${id})" stroke="${shade('#9aa3ad', -50)}" stroke-width="1"/>
++ <rect x="47" y="24" width="6" height="12" rx="2" fill="${trim}"/>`
++ case 6: // goggles
++ return `<path d="M29 46 Q50 40 71 46" fill="none" stroke="#3a2a1a" stroke-width="2.4"/>
++ <circle cx="40" cy="44" r="5.2" fill="#9be7ff" stroke="${trim}" stroke-width="2"/>
++ <circle cx="60" cy="44" r="5.2" fill="#9be7ff" stroke="${trim}" stroke-width="2"/>
++ <circle cx="38.5" cy="42.5" r="1.4" fill="#fff"/><circle cx="58.5" cy="42.5" r="1.4" fill="#fff"/>`
++ default:
++ return ''
++ }
++}
++
++/** A hero's bust; `close` frames the face for small cards. */
++export function heroPortrait(def: HeroDef, close = false): SVGSVGElement {
++ return svg(portraitBox(close), portraitBody(def, `h${++uid}`), 'portrait')
++}
++
++const portraitBox = (close: boolean) => (close ? '13 16 74 90' : '0 0 100 122')
++
++/** The same bust as standalone SVG markup (the share card is drawn from it). */
++export function portraitMarkup(def: HeroDef, close = false): string {
++ return `<svg xmlns="${NS}" viewBox="${portraitBox(close)}">${portraitBody(def, `m${++uid}`)}</svg>`
++}
++
++function portraitBody(def: HeroDef, id: string): string {
++ const l = def.look
++ const hairDark = shade(l.hair, -45)
++ const skinShade = shade(l.skin, -28)
++ const style = Math.abs(l.hairStyle) % BACK_HAIR.length
++ const body = `
++ <defs>
++ <linearGradient id="hair-${id}" x1="0" y1="0" x2="0" y2="1">
++ <stop offset="0" stop-color="${shade(l.hair, 30)}"/><stop offset="1" stop-color="${hairDark}"/>
++ </linearGradient>
++ <linearGradient id="cloth-${id}" x1="0" y1="0" x2="0" y2="1">
++ <stop offset="0" stop-color="${shade(l.outfit, 22)}"/><stop offset="1" stop-color="${shade(l.outfit, -30)}"/>
++ </linearGradient>
++ <linearGradient id="metal-${id}" x1="0" y1="0" x2="0" y2="1">
++ <stop offset="0" stop-color="#e8edf2"/><stop offset="1" stop-color="#8a96a3"/>
++ </linearGradient>
++ </defs>
++ ${l.accessory === 4 ? accessory(4, l, id) : ''}
++ ${style === 7 ? `<circle cx="50" cy="22" r="11" fill="url(#hair-${id})"/>` : ''}
++ <path d="${BACK_HAIR[style]}" fill="url(#hair-${id})"/>
++ <path d="M10 122 C12 104 24 94 50 90 C76 94 88 104 90 122 Z" fill="url(#cloth-${id})"/>
++ <path d="M36 92 L50 108 L64 92" fill="none" stroke="${l.trim}" stroke-width="3" stroke-linejoin="round"/>
++ <path d="M14 112 C22 102 30 98 36 96" fill="none" stroke="${l.trim}" stroke-width="2" opacity="0.7"/>
++ <path d="M86 112 C78 102 70 98 64 96" fill="none" stroke="${l.trim}" stroke-width="2" opacity="0.7"/>
++ <path d="M43 76 L43 93 C46 96 54 96 57 93 L57 76 Z" fill="${skinShade}"/>
++ <ellipse cx="30.5" cy="58" rx="3.6" ry="5" fill="${skinShade}"/>
++ <ellipse cx="69.5" cy="58" rx="3.6" ry="5" fill="${skinShade}"/>
++ <path d="M30 48 C30 30 70 30 70 48 L70 58 C70 72 60 82 50 82 C40 82 30 72 30 58 Z" fill="${l.skin}"/>
++ <ellipse cx="38" cy="66" rx="4" ry="2.2" fill="#ff7a8a" opacity="0.28"/>
++ <ellipse cx="62" cy="66" rx="4" ry="2.2" fill="#ff7a8a" opacity="0.28"/>
++ <path d="M35 51 Q41 48 46 51" fill="none" stroke="${hairDark}" stroke-width="1.6" stroke-linecap="round"/>
++ <path d="M54 51 Q59 48 65 51" fill="none" stroke="${hairDark}" stroke-width="1.6" stroke-linecap="round"/>
++ <ellipse cx="41" cy="58" rx="5" ry="4.2" fill="#fff"/>
++ <ellipse cx="59" cy="58" rx="5" ry="4.2" fill="#fff"/>
++ <circle cx="41.4" cy="58.4" r="3.3" fill="${l.eyes}"/>
++ <circle cx="59.4" cy="58.4" r="3.3" fill="${l.eyes}"/>
++ <circle cx="41.6" cy="58.8" r="1.6" fill="#1b1b2a"/>
++ <circle cx="59.6" cy="58.8" r="1.6" fill="#1b1b2a"/>
++ <circle cx="40.2" cy="57.2" r="1.1" fill="#fff"/>
++ <circle cx="58.2" cy="57.2" r="1.1" fill="#fff"/>
++ <path d="M35.6 55.6 Q41 52.4 46.4 55.8" fill="none" stroke="#2a1f2a" stroke-width="1.4" stroke-linecap="round"/>
++ <path d="M53.6 55.8 Q59 52.4 64.4 55.6" fill="none" stroke="#2a1f2a" stroke-width="1.4" stroke-linecap="round"/>
++ <path d="M50 62 L49 66 L51 66.4" fill="none" stroke="${skinShade}" stroke-width="1" stroke-linecap="round"/>
++ <path d="M45.5 71 Q50 74 54.5 71" fill="none" stroke="#a0464f" stroke-width="1.5" stroke-linecap="round"/>
++ <path d="${FRONT_HAIR[style]}" fill="url(#hair-${id})"/>
++ ${l.accessory !== 4 ? accessory(l.accessory, l, id) : ''}
++ `
++ return body
++}
++
++/** An enemy, cute rather than grim, coloured by its element. */
++export function enemyArt(def: EnemyDef): SVGSVGElement {
++ const c = ELEMENT_COLOR[def.element]
++ const dark = shade(c, -70)
++ const light = shade(c, 60)
++ const id = `e${++uid}`
++ const eyes = (y: number, gap = 9, r = 4) => `
++ <ellipse cx="${50 - gap}" cy="${y}" rx="${r}" ry="${r * 1.2}" fill="#fff"/>
++ <ellipse cx="${50 + gap}" cy="${y}" rx="${r}" ry="${r * 1.2}" fill="#fff"/>
++ <circle cx="${50 - gap + 0.8}" cy="${y + 0.8}" r="${r * 0.6}" fill="#1b1b2a"/>
++ <circle cx="${50 + gap + 0.8}" cy="${y + 0.8}" r="${r * 0.6}" fill="#1b1b2a"/>
++ <circle cx="${50 - gap - 0.6}" cy="${y - 1}" r="${r * 0.28}" fill="#fff"/>
++ <circle cx="${50 + gap - 0.6}" cy="${y - 1}" r="${r * 0.28}" fill="#fff"/>`
++ const grad = `<defs><radialGradient id="g-${id}" cx="0.35" cy="0.3" r="0.8">
++ <stop offset="0" stop-color="${light}"/><stop offset="0.55" stop-color="${c}"/><stop offset="1" stop-color="${dark}"/>
++ </radialGradient></defs>`
++ const fill = `url(#g-${id})`
++ let body = ''
++ switch (def.kind) {
++ case 'slime':
++ body = `<path d="M14 84 C10 60 30 30 50 26 C70 30 90 60 86 84 C80 92 20 92 14 84 Z" fill="${fill}"/>
++ <ellipse cx="36" cy="40" rx="8" ry="5" fill="#fff" opacity="0.45"/>${eyes(60, 11, 5)}
++ <path d="M44 74 Q50 78 56 74" fill="none" stroke="${dark}" stroke-width="2" stroke-linecap="round"/>`
++ break
++ case 'wisp':
++ body = `<path d="M50 10 C60 26 80 34 78 60 C76 80 62 90 50 90 C38 90 24 80 22 60 C20 40 36 34 40 22 C44 30 46 34 50 10 Z" fill="${fill}" opacity="0.95"/>
++ <path d="M50 30 C56 42 66 48 64 62 C62 74 56 80 50 80 C44 80 38 74 36 62 C36 50 46 44 50 30 Z" fill="${light}" opacity="0.55"/>${eyes(60, 8, 4)}`
++ break
++ case 'golem':
++ body = `<path d="M22 34 L40 20 L62 22 L80 36 L84 74 L68 90 L32 90 L16 74 Z" fill="${fill}" stroke="${dark}" stroke-width="2"/>
++ <path d="M30 46 L44 40 M58 40 L72 48 M34 72 L48 78 L64 72" stroke="${dark}" stroke-width="2" fill="none"/>
++ <rect x="34" y="50" width="12" height="7" rx="2" fill="${light}"/><rect x="54" y="50" width="12" height="7" rx="2" fill="${light}"/>`
++ break
++ case 'imp':
++ body = `<path d="M30 30 L22 10 L40 24 Z M70 30 L78 10 L60 24 Z" fill="${dark}"/>
++ <circle cx="50" cy="54" r="30" fill="${fill}"/>${eyes(50, 10, 5)}
++ <path d="M40 66 Q50 74 60 66" fill="none" stroke="${dark}" stroke-width="2.4" stroke-linecap="round"/>
++ <path d="M44 68 L46 72 L48 68 M52 68 L54 72 L56 68" fill="#fff"/>`
++ break
++ case 'drone':
++ body = `<rect x="18" y="14" width="64" height="5" rx="2.5" fill="${dark}"/><rect x="47" y="16" width="6" height="14" fill="${dark}"/>
++ <ellipse cx="50" cy="56" rx="32" ry="26" fill="${fill}" stroke="${dark}" stroke-width="2"/>
++ <circle cx="50" cy="56" r="13" fill="#1b1b2a"/><circle cx="50" cy="56" r="8" fill="${light}"/><circle cx="47" cy="53" r="2.6" fill="#fff"/>
++ <path d="M22 76 L14 88 M78 76 L86 88" stroke="${dark}" stroke-width="3" stroke-linecap="round"/>`
++ break
++ case 'beetle':
++ body = `<path d="M20 80 L10 90 M30 84 L24 96 M70 84 L76 96 M80 80 L90 90" stroke="${dark}" stroke-width="3" stroke-linecap="round"/>
++ <ellipse cx="50" cy="60" rx="34" ry="28" fill="${fill}" stroke="${dark}" stroke-width="2"/>
++ <path d="M50 32 L50 88" stroke="${dark}" stroke-width="2"/>
++ <circle cx="50" cy="30" r="14" fill="${dark}"/><path d="M42 20 L36 8 M58 20 L64 8" stroke="${dark}" stroke-width="2.4" stroke-linecap="round"/>
++ <circle cx="45" cy="29" r="2.6" fill="${light}"/><circle cx="55" cy="29" r="2.6" fill="${light}"/>`
++ break
++ case 'specter':
++ body = `<path d="M22 92 L22 46 C22 24 36 12 50 12 C64 12 78 24 78 46 L78 92 L68 84 L59 92 L50 84 L41 92 L32 84 Z" fill="${fill}" opacity="0.92"/>
++ <path d="M30 50 C30 30 70 30 70 50 C70 62 30 62 30 50 Z" fill="#14101f"/>
++ <ellipse cx="41" cy="48" rx="4" ry="5" fill="${light}"/><ellipse cx="59" cy="48" rx="4" ry="5" fill="${light}"/>`
++ break
++ case 'drake':
++ body = `<path d="M28 34 L18 12 L38 28 Z M72 34 L82 12 L62 28 Z" fill="${dark}"/>
++ <path d="M18 52 C18 30 82 30 82 52 L80 70 C76 86 24 86 20 70 Z" fill="${fill}" stroke="${dark}" stroke-width="2"/>
++ <path d="M34 74 C40 84 60 84 66 74 L60 76 L56 72 L50 77 L44 72 L40 76 Z" fill="#fff"/>
++ ${eyes(52, 13, 5)}
++ <circle cx="44" cy="65" r="1.8" fill="${dark}"/><circle cx="56" cy="65" r="1.8" fill="${dark}"/>`
++ break
++ }
++ if (def.boss) {
++ body += `<path d="M34 12 L38 2 L44 9 L50 0 L56 9 L62 2 L66 12 Z" fill="#ffd25a" stroke="#a56b00" stroke-width="1"/>`
++ }
++ return svg('0 0 100 100', grad + body, 'enemy')
++}
++
++const ICONS: Record<string, string> = {
++ gold: `<circle cx="12" cy="12" r="9" fill="#ffcf3f" stroke="#b07a00" stroke-width="1.6"/><circle cx="12" cy="12" r="5.6" fill="none" stroke="#d99c00" stroke-width="1.4"/><path d="M9.4 9.6 L12 8 L14.6 9.6" stroke="#fff6c8" stroke-width="1.2" fill="none" stroke-linecap="round"/>`,
++ gems: `<path d="M6 9 L9 4.5 L15 4.5 L18 9 L12 20 Z" fill="#ff5fc8" stroke="#a31f7d" stroke-width="1.3" stroke-linejoin="round"/><path d="M6 9 L18 9 M9 4.5 L12 9 L15 4.5 M12 9 L12 20" stroke="#ffd1f0" stroke-width="0.9" fill="none"/>`,
++ sigils: `<circle cx="12" cy="12" r="9" fill="#7c4dff" stroke="#3b1d99" stroke-width="1.4"/><path d="M12 5 L13.8 10.2 L19 12 L13.8 13.8 L12 19 L10.2 13.8 L5 12 L10.2 10.2 Z" fill="#e9dcff"/>`,
++ energy: `<path d="M13.5 2.5 L5 13.5 L11 13.5 L9.5 21.5 L19 9.5 L12.8 9.5 Z" fill="#5ee0ff" stroke="#0a7fa3" stroke-width="1.3" stroke-linejoin="round"/>`,
++ battle: `<path d="M4 4 L13 13 M4 4 L4 8 M4 4 L8 4 M20 4 L11 13 M20 4 L20 8 M20 4 L16 4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" fill="none"/><path d="M7 15 L5 17 L7 19 L9 17 Z M17 15 L19 17 L17 19 L15 17 Z" fill="currentColor"/><path d="M9 13 L6.5 15.5 M15 13 L17.5 15.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>`,
++ heroes: `<path d="M12 3 C7 3 5 7 5 11 L5 15 L8 15 L8 11 L16 11 L16 15 L19 15 L19 11 C19 7 17 3 12 3 Z" fill="currentColor"/><path d="M8 15 L8 18 C8 20 10 21 12 21 C14 21 16 20 16 18 L16 15" fill="none" stroke="currentColor" stroke-width="2"/><rect x="11" y="11" width="2" height="7" fill="currentColor"/>`,
++ summon: `<path d="M12 2 L15 9 L22 12 L15 15 L12 22 L9 15 L2 12 L9 9 Z" fill="currentColor"/><circle cx="12" cy="12" r="2.4" fill="var(--cg-bg, #120c2a)"/>`,
++ quests: `<path d="M6 3 L16 3 C18 3 19 4 19 6 L19 18 C19 20 18 21 16 21 L8 21 C6 21 5 20 5 18 L5 6" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 8 L15 8 M9 12 L15 12 M9 16 L13 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`,
++ shop: `<path d="M5 8 L19 8 L18 21 L6 21 Z" fill="currentColor"/><path d="M9 10 L9 6 C9 4 10.5 3 12 3 C13.5 3 15 4 15 6 L15 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`,
++ trophy: `<path d="M7 3 L17 3 L17 9 C17 12 15 14 12 14 C9 14 7 12 7 9 Z" fill="currentColor"/><path d="M7 5 L4 5 C4 8 5 10 7 10 M17 5 L20 5 C20 8 19 10 17 10" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 14 L12 18 M8 21 L16 21 L15 18 L9 18 Z" stroke="currentColor" stroke-width="1.8" fill="currentColor"/>`,
++ chest: `<rect x="3" y="9" width="18" height="11" rx="1.5" fill="#b8742a" stroke="#6b3d0f" stroke-width="1.4"/><path d="M3 9 C3 5 6 4 12 4 C18 4 21 5 21 9 Z" fill="#d18a35" stroke="#6b3d0f" stroke-width="1.4"/><rect x="10" y="9" width="4" height="5" rx="1" fill="#ffd25a" stroke="#6b3d0f" stroke-width="1"/><path d="M3 13 L21 13" stroke="#6b3d0f" stroke-width="1"/>`,
++ star: `<path d="M12 2.5 L14.9 8.6 L21.5 9.4 L16.6 13.9 L17.9 20.5 L12 17.2 L6.1 20.5 L7.4 13.9 L2.5 9.4 L9.1 8.6 Z" fill="currentColor"/>`,
++ close: `<path d="M6 6 L18 18 M18 6 L6 18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>`,
++ menu: `<path d="M4 7 L20 7 M4 12 L20 12 M4 17 L20 17" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>`,
++ guardian: `<path d="M12 2 L20 5 L20 11 C20 16 16.5 20 12 22 C7.5 20 4 16 4 11 L4 5 Z" fill="currentColor"/>`,
++ blade: `<path d="M18.5 2.5 L21.5 2.5 L21.5 5.5 L10 17 L7 14 Z" fill="currentColor"/><path d="M5 12 L12 19 M6 18 L3 21" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>`,
++ arcanist: `<path d="M12 2 L13.6 6.4 L18 8 L13.6 9.6 L12 14 L10.4 9.6 L6 8 L10.4 6.4 Z" fill="currentColor"/><path d="M12 14 L12 22" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>`,
++ ranger: `<path d="M6 3 C14 6 14 18 6 21" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M6 3 L6 21 M4 12 L21 12 M18 9 L21 12 L18 15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/>`,
++ mender: `<path d="M9.5 3 L14.5 3 L14.5 9.5 L21 9.5 L21 14.5 L14.5 14.5 L14.5 21 L9.5 21 L9.5 14.5 L3 14.5 L3 9.5 L9.5 9.5 Z" fill="currentColor"/>`,
++ herald: `<path d="M5 3 L5 21" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M6 4 L19 4 L16 8.5 L19 13 L6 13 Z" fill="currentColor"/>`,
++ board: `<rect x="3" y="12" width="5" height="9" rx="1" fill="currentColor"/><rect x="9.5" y="5" width="5" height="16" rx="1" fill="currentColor"/><rect x="16" y="9" width="5" height="12" rx="1" fill="currentColor"/>`,
++ lock: `<rect x="5" y="10" width="14" height="11" rx="2" fill="currentColor"/><path d="M8 10 L8 7 C8 4.5 10 3 12 3 C14 3 16 4.5 16 7 L16 10" fill="none" stroke="currentColor" stroke-width="2"/>`,
++ sweep: `<path d="M4 20 L14 10 M12 6 L18 12 M14 4 L20 10 L16 14 L10 8 Z" stroke="currentColor" stroke-width="2" fill="none" stroke-linejoin="round" stroke-linecap="round"/>`,
++}
++
++export function icon(name: string, cls = 'ico'): SVGSVGElement {
++ return svg('0 0 24 24', ICONS[name] ?? '', cls)
++}
++
++const ELEMENT_GLYPH: Record<Element, string> = {
++ flame:
++ '<path d="M12 2.5 C14.5 7 19 9 18 14.5 C17.4 18.4 14.8 21 12 21 C8.6 21 6 18.6 6 15.2 C6 12 8.4 10.6 9.6 7.6 C10.8 9.6 11.6 10 12 2.5 Z" fill="currentColor"/>',
++ tide: '<path d="M12 2.5 C15.4 8 18.5 11 18.5 15 A6.5 6.5 0 0 1 5.5 15 C5.5 11 8.6 8 12 2.5 Z" fill="currentColor"/>',
++ grove:
++ '<path d="M4.5 19.5 C4.5 10 10 4.5 19.5 4.5 C19.5 14 14 19.5 4.5 19.5 Z" fill="currentColor"/><path d="M5.5 18.5 L14 10" stroke="rgba(0,0,0,0.35)" stroke-width="1.6" stroke-linecap="round"/>',
++ dawn: '<circle cx="12" cy="12" r="4.6" fill="currentColor"/><path d="M12 2 V5 M12 19 V22 M2 12 H5 M19 12 H22 M4.9 4.9 L7 7 M17 17 L19.1 19.1 M4.9 19.1 L7 17 M17 7 L19.1 4.9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
++ dusk: '<path d="M14.5 2.8 A9.4 9.4 0 1 0 21.2 15 A7.4 7.4 0 1 1 14.5 2.8 Z" fill="currentColor"/>',
++}
++
++/** The small element badge: a coloured disc with the element's glyph (flame, drop, leaf, sun, moon). */
++export function elementBadge(e: Element): HTMLElement {
++ const b = document.createElement('span')
++ b.className = `el el-${e}`
++ b.title = ELEMENT_NAME[e]
++ b.setAttribute('role', 'img')
++ b.setAttribute('aria-label', ELEMENT_NAME[e])
++ b.append(svg('0 0 24 24', ELEMENT_GLYPH[e], 'ico'))
++ return b
++}
ddiff --git a/apps/client/src/casual/casual.css b/apps/client/src/casual/casual.css
nnew file mode 100644
iindex 0000000..b11cf6f
---- /dev/null
++++ b/apps/client/src/casual/casual.css
@@@ -0,0 +1,1733 @@
++/* Vibe Heroes and the casual line (plan T22): a phone-first column, the same on desktop with a frame around it. */
++
++.cg {
++ --cg-bg: #120c2a;
++ --cg-raise: #1d1442;
++ --cg-card: rgba(255, 255, 255, 0.06);
++ --cg-card-2: rgba(255, 255, 255, 0.1);
++ --cg-line: rgba(255, 255, 255, 0.13);
++ --cg-text: #f4f1ff;
++ --cg-dim: #bdb4dc;
++ --cg-gold: #ffd25a;
++ --cg-gold-2: #ff9f1c;
++ --cg-ink: #2a1600;
++ --cg-red: #ff3b5c;
++ --r-common: #a3adbb;
++ --r-rare: #45a8ff;
++ --r-epic: #b979ff;
++ --r-legendary: #ffcf3f;
++ position: relative;
++ overflow: hidden;
++ -webkit-tap-highlight-color: transparent;
++ user-select: none;
++}
++@media (min-width: 560px) {
++ body {
++ background:
++ radial-gradient(800px 500px at 20% 10%, rgba(139, 92, 246, 0.25), transparent 70%),
++ radial-gradient(700px 500px at 85% 80%, rgba(34, 211, 238, 0.14), transparent 70%), #0b0820;
++ }
++ .cg {
++ margin-top: 12px;
++ height: calc(100dvh - 56px - 24px);
++ border-radius: 22px;
++ border: 1px solid var(--cg-line);
++ box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
++ }
++}
++.cg *,
++.cg *::before,
++.cg *::after {
++ box-sizing: border-box;
++}
++.cg :focus-visible {
++ outline: 2px solid var(--cg-gold);
++ outline-offset: 2px;
++}
++.cg h1,
++.cg h2,
++.cg h3,
++.cg h4,
++.cg p {
++ margin: 0;
++}
++.cg .muted {
++ color: var(--cg-dim);
++}
++.cg .center {
++ text-align: center;
++}
++.cg .ico {
++ width: 20px;
++ height: 20px;
++ flex: none;
++ display: inline-block;
++ vertical-align: middle;
++}
++.cg.is-busy {
++ cursor: progress;
++}
++.boot-msg {
++ margin: auto;
++ padding: 24px;
++ text-align: center;
++ display: grid;
++ gap: 14px;
++}
++
++/* ── Buttons ── */
++:where(.cg) button {
++ font: inherit;
++ color: inherit;
++}
++.btn {
++ appearance: none;
++ border: 1px solid var(--cg-line);
++ background: linear-gradient(180deg, #3a2d6e, #2a1f55);
++ color: var(--cg-text);
++ border-radius: 12px;
++ padding: 10px 16px;
++ font-weight: 700;
++ cursor: pointer;
++ display: inline-flex;
++ align-items: center;
++ justify-content: center;
++ gap: 6px;
++ min-height: 44px;
++ transition:
++ transform 0.08s ease,
++ filter 0.15s ease;
++ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.35);
++}
++.btn:active:not(:disabled) {
++ transform: translateY(1px) scale(0.98);
++}
++.btn:disabled {
++ filter: grayscale(0.8) brightness(0.7);
++ cursor: not-allowed;
++}
++.btn-gold {
++ background: linear-gradient(180deg, #fff1a8 0%, var(--cg-gold) 40%, var(--cg-gold-2) 100%);
++ color: var(--cg-ink);
++ border-color: #b86a00;
++ box-shadow:
++ 0 3px 0 #8a4b00,
++ inset 0 1px 0 rgba(255, 255, 255, 0.7);
++}
++.btn-violet {
++ background: linear-gradient(180deg, #b69bff 0%, #8b5cf6 50%, #6234d9 100%);
++ border-color: #3b1d99;
++ box-shadow:
++ 0 3px 0 #2c1475,
++ inset 0 1px 0 rgba(255, 255, 255, 0.45);
++}
++.btn-glass {
++ background: rgba(0, 0, 0, 0.3);
++ backdrop-filter: blur(6px);
++}
++.btn-small {
++ padding: 6px 12px;
++ min-height: 36px;
++ font-size: 14px;
++ border-radius: 10px;
++}
++.btn-wide {
++ width: 100%;
++ margin-top: 12px;
++}
++.btn-link {
++ background: none;
++ border: 0;
++ box-shadow: none;
++ color: var(--cg-gold);
++ text-decoration: underline;
++ min-height: 40px;
++ width: 100%;
++}
++.icon-btn {
++ appearance: none;
++ background: rgba(0, 0, 0, 0.3);
++ border: 1px solid var(--cg-line);
++ border-radius: 10px;
++ width: 40px;
++ height: 40px;
++ display: grid;
++ place-items: center;
++ cursor: pointer;
++ flex: none;
++}
++.icon-btn .ico {
++ width: 22px;
++ height: 22px;
++}
++.pulse {
++ animation: pulse 1.4s ease-in-out infinite;
++}
++@keyframes pulse {
++ 50% {
++ box-shadow:
++ 0 0 0 6px rgba(255, 210, 90, 0.25),
++ 0 3px 0 #8a4b00;
++ }
++}
++
++/* ── Top bar ── */
++.cg-top {
++ display: flex;
++ align-items: center;
++ gap: 5px;
++ padding: 8px 8px 6px;
++ background: linear-gradient(180deg, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.1));
++ position: relative;
++ z-index: 2;
++}
++.chip {
++ flex: 1 1 0;
++ min-width: 0;
++ display: flex;
++ align-items: center;
++ gap: 4px;
++ height: 32px;
++ padding: 0 8px 0 4px;
++ border-radius: 999px;
++ background: rgba(0, 0, 0, 0.4);
++ border: 1px solid var(--cg-line);
++}
++.chip b {
++ font-size: 13px;
++ font-variant-numeric: tabular-nums;
++ white-space: nowrap;
++ overflow: hidden;
++ text-overflow: ellipsis;
++}
++.chip-energy {
++ flex: 1.25 1 0;
++}
++.chip-stack {
++ display: flex;
++ flex-direction: column;
++ line-height: 1;
++ min-width: 0;
++}
++.chip-stack small {
++ font-size: 10px;
++ color: #8fe9ff;
++ font-variant-numeric: tabular-nums;
++}
++.chip-stack small:empty {
++ display: none;
++}
++.top-menu {
++ width: 34px;
++ height: 32px;
++}
++
++/* ── View + tabs ── */
++.cg-view {
++ flex: 1;
++ overflow-y: auto;
++ overscroll-behavior: contain;
++ padding: 8px 12px 18px;
++ display: block;
++}
++.cg-view > div {
++ display: grid;
++ gap: 12px;
++}
++.cg-tabs {
++ display: grid;
++ grid-template-columns: repeat(5, 1fr);
++ background: linear-gradient(180deg, #1c1340, #0e0922);
++ border-top: 1px solid var(--cg-line);
++ padding-bottom: env(safe-area-inset-bottom);
++ position: relative;
++ z-index: 2;
++}
++.tab {
++ appearance: none;
++ background: none;
++ border: 0;
++ padding: 7px 0 6px;
++ display: flex;
++ flex-direction: column;
++ align-items: center;
++ gap: 2px;
++ color: var(--cg-dim);
++ font-size: 11px;
++ font-weight: 700;
++ cursor: pointer;
++ position: relative;
++ min-height: 54px;
++}
++.tab .ico {
++ width: 24px;
++ height: 24px;
++}
++.tab.is-on {
++ color: var(--cg-gold);
++}
++.tab.is-on::before {
++ content: '';
++ position: absolute;
++ top: 0;
++ left: 25%;
++ right: 25%;
++ height: 3px;
++ border-radius: 0 0 3px 3px;
++ background: var(--cg-gold);
++ box-shadow: 0 0 12px var(--cg-gold);
++}
++.dot {
++ position: absolute;
++ top: 6px;
++ right: calc(50% - 20px);
++ width: 10px;
++ height: 10px;
++ border-radius: 50%;
++ background: var(--cg-red);
++ box-shadow: 0 0 0 2px var(--cg-bg);
++}
++
++/* ── Panels ── */
++.panel {
++ background: var(--cg-card);
++ border: 1px solid var(--cg-line);
++ border-radius: 16px;
++ padding: 12px;
++ display: grid;
++ gap: 10px;
++}
++.panel-head {
++ display: flex;
++ align-items: center;
++ gap: 8px;
++}
++.panel-head h2 {
++ font-size: 16px;
++ flex: 1;
++}
++.hint {
++ text-align: center;
++ color: #fff6c8;
++ font-weight: 700;
++ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
++}
++
++/* ── Elements ── */
++.el {
++ display: inline-grid;
++ place-items: center;
++ width: 19px;
++ height: 19px;
++ border-radius: 50%;
++ border: 1.5px solid rgba(255, 255, 255, 0.85);
++ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
++ flex: none;
++}
++.el .ico {
++ width: 12px;
++ height: 12px;
++ color: #fff;
++}
++.el-flame {
++ background: #ff6b3d;
++}
++.el-tide {
++ background: #2f8ff0;
++}
++.el-grove {
++ background: #2fa84f;
++}
++.el-dawn {
++ background: #e0a400;
++}
++.el-dusk {
++ background: #8b5cf6;
++}
++.el-bg-flame {
++ background: radial-gradient(circle at 50% 30%, #ffb38a, #e2502a 75%);
++}
++.el-bg-tide {
++ background: radial-gradient(circle at 50% 30%, #a6dcff, #2a7fd8 75%);
++}
++.el-bg-grove {
++ background: radial-gradient(circle at 50% 30%, #b8f0b0, #2f9a4c 75%);
++}
++.el-bg-dawn {
++ background: radial-gradient(circle at 50% 30%, #fff1b0, #e0a400 75%);
++}
++.el-bg-dusk {
++ background: radial-gradient(circle at 50% 30%, #d6c2ff, #6a3fd0 75%);
++}
++
++/* ── Hero cards ── */
++.hero-card {
++ --rc: var(--r-common);
++ position: relative;
++ border-radius: 12px;
++ aspect-ratio: 3 / 4;
++ overflow: hidden;
++ border: 2px solid var(--rc);
++ box-shadow:
++ 0 0 0 1px rgba(0, 0, 0, 0.5),
++ 0 4px 10px rgba(0, 0, 0, 0.35);
++ background: #241a4d;
++}
++.r-rare {
++ --rc: var(--r-rare);
++}
++.r-epic {
++ --rc: var(--r-epic);
++}
++.r-legendary {
++ --rc: var(--r-legendary);
++}
++.hero-card.r-legendary {
++ box-shadow:
++ 0 0 0 1px rgba(0, 0, 0, 0.5),
++ 0 0 14px rgba(255, 207, 63, 0.45);
++}
++.hero-art {
++ position: absolute;
++ inset: 0;
++}
++.hero-art .portrait,
++.hero-big .portrait,
++.rw-face .portrait,
++.banner-hero .portrait,
++.unit-art .portrait {
++ width: 100%;
++ height: 100%;
++ display: block;
++}
++.hero-card .el {
++ position: absolute;
++ top: 4px;
++ left: 4px;
++}
++.hero-card .lv {
++ position: absolute;
++ top: 4px;
++ right: 5px;
++ font-size: 11px;
++ font-weight: 800;
++ text-shadow:
++ 0 1px 2px #000,
++ 0 0 4px #000;
++}
++.hero-card .stars {
++ position: absolute;
++ left: 0;
++ right: 0;
++ bottom: 18px;
++ justify-content: center;
++}
++.hero-card.small .stars {
++ bottom: 3px;
++}
++.hero-card .name {
++ position: absolute;
++ left: 0;
++ right: 0;
++ bottom: 0;
++ font-size: 11px;
++ font-weight: 800;
++ text-align: center;
++ padding: 2px 2px 3px;
++ background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.75));
++}
++.hero-card.locked .hero-art {
++ filter: grayscale(0.85) brightness(0.6);
++}
++.hero-card.empty {
++ display: grid;
++ place-items: center;
++ font-size: 24px;
++ color: var(--cg-dim);
++ border-style: dashed;
++ background: rgba(0, 0, 0, 0.2);
++}
++.hero-card .in-team {
++ position: absolute;
++ top: 24px;
++ right: 4px;
++ background: var(--cg-gold);
++ color: var(--cg-ink);
++ font-size: 9px;
++ font-weight: 900;
++ letter-spacing: 0.04em;
++ text-transform: uppercase;
++ border-radius: 6px;
++ padding: 1px 5px;
++ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
++}
++.hero-card .dot,
++.pick .dot {
++ top: 4px;
++ right: 4px;
++}
++.hero-card .slot-no {
++ position: absolute;
++ inset: auto auto 22px 4px;
++ width: 20px;
++ height: 20px;
++ border-radius: 50%;
++ background: var(--cg-gold);
++ color: var(--cg-ink);
++ font-weight: 900;
++ font-size: 12px;
++ display: grid;
++ place-items: center;
++}
++.shard-bar {
++ position: absolute;
++ left: 5px;
++ right: 5px;
++ bottom: 18px;
++ height: 14px;
++ border-radius: 7px;
++ background: rgba(0, 0, 0, 0.6);
++ overflow: hidden;
++ border: 1px solid rgba(255, 255, 255, 0.25);
++}
++.shard-bar i {
++ position: absolute;
++ inset: 0 auto 0 0;
++ background: linear-gradient(90deg, #7c4dff, #c084fc);
++}
++.shard-bar b {
++ position: relative;
++ display: block;
++ text-align: center;
++ font-size: 10px;
++ line-height: 12px;
++}
++.stars {
++ display: inline-flex;
++ gap: 1px;
++}
++.stars .star {
++ width: 12px;
++ height: 12px;
++ color: rgba(255, 255, 255, 0.25);
++}
++.stars .star.on {
++ color: var(--cg-gold);
++ filter: drop-shadow(0 1px 0 #7a4200);
++}
++.hero-grid {
++ display: grid;
++ grid-template-columns: repeat(4, 1fr);
++ gap: 8px;
++}
++@media (max-width: 360px) {
++ .hero-grid {
++ grid-template-columns: repeat(3, 1fr);
++ }
++}
++.pick {
++ appearance: none;
++ background: none;
++ border: 0;
++ padding: 0;
++ cursor: pointer;
++ position: relative;
++ border-radius: 12px;
++ display: block;
++ text-align: left;
++}
++.pick.is-on .hero-card {
++ outline: 3px solid var(--cg-gold);
++ outline-offset: 1px;
++}
++
++/* ── Stage card ── */
++.stage-card {
++ position: relative;
++ border-radius: 18px;
++ padding: 14px;
++ display: grid;
++ gap: 10px;
++ background:
++ linear-gradient(180deg, rgba(10, 6, 30, 0.15), rgba(10, 6, 30, 0.75)),
++ linear-gradient(160deg, var(--sky-a, #7dd3fc), var(--sky-b, #bbf7d0));
++ border: 1px solid rgba(255, 255, 255, 0.25);
++ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
++ overflow: hidden;
++}
++.stage-card::after {
++ content: '';
++ position: absolute;
++ inset: auto -20% -40% -20%;
++ height: 60%;
++ background: radial-gradient(closest-side, rgba(255, 255, 255, 0.18), transparent);
++ pointer-events: none;
++}
++.stage-head {
++ display: flex;
++ align-items: flex-start;
++ gap: 8px;
++}
++.stage-head > div {
++ flex: 1;
++}
++.stage-head small {
++ font-weight: 800;
++ letter-spacing: 0.08em;
++ text-transform: uppercase;
++ font-size: 11px;
++ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
++}
++.stage-head h1 {
++ font-size: 24px;
++ font-weight: 900;
++ text-shadow:
++ 0 2px 0 rgba(0, 0, 0, 0.45),
++ 0 0 18px rgba(0, 0, 0, 0.35);
++}
++.chapter-track {
++ display: grid;
++ grid-template-columns: repeat(10, 1fr);
++ gap: 4px;
++}
++.chapter-track i {
++ height: 6px;
++ border-radius: 3px;
++ background: rgba(0, 0, 0, 0.35);
++}
++.chapter-track i.done {
++ background: var(--cg-gold);
++}
++.chapter-track i.here {
++ background: #fff;
++ box-shadow: 0 0 8px #fff;
++}
++.chapter-track i.boss {
++ background: rgba(255, 59, 92, 0.6);
++}
++.foes {
++ display: flex;
++ justify-content: center;
++ gap: 4px;
++ min-height: 74px;
++ align-items: flex-end;
++}
++.foe-mini {
++ width: 62px;
++ height: 62px;
++ animation: bob 2.4s ease-in-out infinite;
++ filter: drop-shadow(0 6px 4px rgba(0, 0, 0, 0.35));
++}
++.foe-mini:nth-child(2n) {
++ animation-delay: -0.8s;
++}
++.foe-mini:nth-child(3n) {
++ animation-delay: -1.5s;
++}
++.foe-mini.boss {
++ width: 86px;
++ height: 86px;
++}
++.foe-mini .enemy {
++ width: 100%;
++ height: 100%;
++}
++@keyframes bob {
++ 50% {
++ transform: translateY(-5px);
++ }
++}
++.stage-meta {
++ display: flex;
++ flex-wrap: wrap;
++ justify-content: center;
++ gap: 6px;
++ font-size: 12px;
++ font-weight: 700;
++}
++.stage-meta span {
++ background: rgba(0, 0, 0, 0.45);
++ border-radius: 999px;
++ padding: 3px 10px;
++}
++.stage-meta .ok {
++ color: #9ff0a8;
++}
++.stage-meta .warn {
++ color: #ffb4a8;
++}
++.first-reward {
++ display: flex;
++ align-items: center;
++ gap: 8px;
++ justify-content: center;
++ flex-wrap: wrap;
++}
++.first-reward > small {
++ font-weight: 800;
++ text-transform: uppercase;
++ font-size: 10px;
++ letter-spacing: 0.08em;
++}
++.btn-battle {
++ width: 100%;
++ min-height: 60px;
++ flex-direction: column;
++ gap: 0;
++ border-radius: 16px;
++}
++.btn-battle .big,
++.btn-summon .big {
++ font-size: 22px;
++ font-weight: 900;
++ letter-spacing: 0.02em;
++}
++.btn-battle small,
++.btn-summon small {
++ display: inline-flex;
++ align-items: center;
++ gap: 3px;
++ font-size: 12px;
++ font-weight: 800;
++}
++.btn-battle small .ico,
++.btn-summon small .ico {
++ width: 15px;
++ height: 15px;
++}
++
++/* ── Team, idle, login ── */
++.team-strip {
++ display: grid;
++ grid-template-columns: repeat(5, 1fr);
++ gap: 6px;
++}
++.idle {
++ grid-template-columns: auto 1fr auto;
++ align-items: center;
++}
++.idle .chest {
++ width: 46px;
++ height: 46px;
++}
++.idle-text {
++ display: grid;
++ gap: 1px;
++ min-width: 0;
++}
++.idle-text h2 {
++ font-size: 15px;
++}
++.idle-text b {
++ color: var(--cg-gold);
++}
++.idle-text small {
++ font-size: 12px;
++}
++.login {
++ grid-template-columns: 1fr auto;
++ align-items: center;
++ background: linear-gradient(135deg, rgba(255, 210, 90, 0.18), rgba(139, 92, 246, 0.18));
++}
++.login h2 {
++ font-size: 15px;
++ margin-bottom: 4px;
++}
++
++/* ── Rewards ── */
++.rewards {
++ display: flex;
++ flex-wrap: wrap;
++ gap: 5px;
++}
++.rw {
++ display: inline-flex;
++ align-items: center;
++ gap: 4px;
++ background: rgba(0, 0, 0, 0.4);
++ border: 1px solid var(--cg-line);
++ border-radius: 999px;
++ padding: 2px 9px 2px 4px;
++ font-size: 12px;
++}
++.rw .ico {
++ width: 16px;
++ height: 16px;
++}
++.rw.r-rare,
++.rw.r-epic,
++.rw.r-legendary,
++.rw.r-common {
++ border-color: var(--rc);
++}
++.rw-face {
++ width: 20px;
++ height: 20px;
++ border-radius: 50%;
++ overflow: hidden;
++ border: 1px solid var(--rc, #fff);
++}
++
++/* ── Sheets ── */
++.sheet-back {
++ position: absolute;
++ inset: 0;
++ background: rgba(5, 3, 15, 0.6);
++ z-index: 30;
++ animation: fade 0.18s ease;
++}
++.sheet {
++ position: absolute;
++ left: 0;
++ right: 0;
++ bottom: 0;
++ max-height: 88%;
++ overflow-y: auto;
++ overscroll-behavior: contain;
++ z-index: 31;
++ background: linear-gradient(180deg, #2a1d5c, #1a1240);
++ border-radius: 20px 20px 0 0;
++ border-top: 1px solid rgba(255, 255, 255, 0.2);
++ padding: 0 14px calc(16px + env(safe-area-inset-bottom));
++ animation: rise 0.2s ease;
++ user-select: text;
++}
++.sheet.out,
++.sheet-back.out {
++ opacity: 0;
++ transition: opacity 0.18s;
++}
++.sheet-head {
++ position: sticky;
++ top: 0;
++ display: flex;
++ align-items: center;
++ gap: 8px;
++ padding: 12px 0 10px;
++ background: linear-gradient(180deg, #2a1d5c 70%, transparent);
++ z-index: 1;
++}
++.sheet-head h2 {
++ flex: 1;
++ font-size: 18px;
++}
++.sheet-body {
++ display: grid;
++ gap: 10px;
++}
++@keyframes rise {
++ from {
++ transform: translateY(40px);
++ opacity: 0;
++ }
++}
++@keyframes fade {
++ from {
++ opacity: 0;
++ }
++}
++
++/* ── Hero sheet ── */
++.hero-head {
++ display: grid;
++ grid-template-columns: 120px 1fr;
++ gap: 12px;
++ align-items: start;
++}
++.hero-big {
++ border-radius: 14px;
++ aspect-ratio: 3 / 4;
++ overflow: hidden;
++ border: 2px solid var(--rc);
++}
++.hero-id {
++ display: grid;
++ gap: 4px;
++}
++.hero-id h3 {
++ font-size: 17px;
++}
++.rarity {
++ font-weight: 900;
++ text-transform: uppercase;
++ letter-spacing: 0.08em;
++ font-size: 11px;
++ color: var(--rc);
++}
++.tags {
++ display: flex;
++ align-items: center;
++ gap: 6px;
++ font-size: 13px;
++}
++.tags .ico {
++ width: 16px;
++ height: 16px;
++ color: var(--cg-dim);
++}
++.bio {
++ font-size: 13px;
++ color: var(--cg-dim);
++}
++.stats {
++ display: grid;
++ grid-template-columns: repeat(5, 1fr);
++ gap: 4px;
++ text-align: center;
++}
++.stats div {
++ background: var(--cg-card);
++ border-radius: 10px;
++ padding: 6px 2px;
++ display: grid;
++}
++.stats small {
++ font-size: 10px;
++ color: var(--cg-dim);
++ font-weight: 800;
++}
++.stats b {
++ font-size: 14px;
++ font-variant-numeric: tabular-nums;
++}
++.skill {
++ background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(255, 210, 90, 0.12));
++ border-radius: 12px;
++ padding: 8px 10px;
++ display: grid;
++ gap: 2px;
++ font-size: 13px;
++}
++.upgrade {
++ display: flex;
++ align-items: center;
++ gap: 8px;
++ justify-content: space-between;
++ background: var(--cg-card);
++ border-radius: 12px;
++ padding: 8px 10px;
++}
++.upgrade > div:first-child {
++ display: grid;
++ min-width: 0;
++}
++.upgrade small {
++ font-size: 12px;
++}
++.upgrade .btn {
++ white-space: nowrap;
++}
++.section-note {
++ margin-top: -6px;
++ font-size: 13px;
++}
++.btn-row {
++ display: flex;
++ gap: 4px;
++}
++.gear-row {
++ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ gap: 6px;
++}
++.gear-slot,
++.gear-choice,
++.menu-item {
++ appearance: none;
++ text-align: left;
++ background: var(--cg-card);
++ border: 1px solid var(--cg-line);
++ border-radius: 12px;
++ padding: 8px;
++ display: grid;
++ gap: 2px;
++ cursor: pointer;
++ min-height: 44px;
++}
++.gear-slot small {
++ text-transform: capitalize;
++ color: var(--cg-dim);
++ font-size: 11px;
++}
++.gear-slot b {
++ font-size: 13px;
++}
++.gear-slot span,
++.gear-choice span {
++ font-size: 11px;
++}
++.gear-slot.r-rare,
++.gear-slot.r-epic,
++.gear-slot.r-legendary,
++.gear-choice {
++ border-color: var(--rc, var(--cg-line));
++}
++.gear-list,
++.menu-list {
++ display: grid;
++ gap: 6px;
++}
++.menu-item {
++ display: flex;
++ align-items: center;
++ gap: 10px;
++ font-weight: 700;
++}
++.cg h4 {
++ font-size: 13px;
++ color: var(--cg-dim);
++ text-transform: uppercase;
++ letter-spacing: 0.08em;
++}
++
++/* ── Summon ── */
++.banner {
++ position: relative;
++ border-radius: 18px;
++ overflow: hidden;
++ min-height: 240px;
++ background:
++ radial-gradient(120% 80% at 80% 20%, rgba(255, 207, 63, 0.35), transparent 60%),
++ linear-gradient(160deg, #3b1d99, #120c2a);
++ border: 1px solid rgba(255, 207, 63, 0.4);
++ display: grid;
++}
++.banner-art {
++ position: absolute;
++ inset: 0;
++}
++.banner-hero {
++ position: absolute;
++ bottom: 0;
++ border-radius: 16px 16px 0 0;
++ overflow: hidden;
++ -webkit-mask-image: linear-gradient(180deg, #000 70%, transparent);
++ mask-image: linear-gradient(180deg, #000 70%, transparent);
++}
++.banner-hero.first {
++ right: 4%;
++ width: 46%;
++ height: 92%;
++}
++.banner-hero.second {
++ right: 30%;
++ width: 30%;
++ height: 66%;
++ opacity: 0.9;
++}
++.banner::before {
++ content: '';
++ position: absolute;
++ inset: 0;
++ z-index: 1;
++ background: linear-gradient(
++ 90deg,
++ rgba(18, 12, 42, 0.92) 0%,
++ rgba(18, 12, 42, 0.6) 42%,
++ transparent 68%
++ );
++ pointer-events: none;
++}
++.banner-text {
++ position: relative;
++ z-index: 2;
++ padding: 14px;
++ width: 60%;
++ display: grid;
++ gap: 4px;
++ align-content: start;
++ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
++}
++.banner-text h1 {
++ text-shadow: none;
++ font-size: 28px;
++ font-weight: 900;
++ background: linear-gradient(180deg, #fff6c8, #ffd25a 50%, #ff9f1c);
++ -webkit-background-clip: text;
++ background-clip: text;
++ color: transparent;
++ filter: drop-shadow(0 2px 0 #5a2a00);
++}
++.banner-text small {
++ font-weight: 800;
++ text-transform: uppercase;
++ letter-spacing: 0.08em;
++ font-size: 10px;
++}
++.banner-text p {
++ font-size: 13px;
++}
++.pity {
++ display: grid;
++ gap: 6px;
++ font-size: 13px;
++ text-align: center;
++}
++.pity-bar,
++.progress {
++ height: 8px;
++ border-radius: 4px;
++ background: rgba(0, 0, 0, 0.45);
++ overflow: hidden;
++ display: block;
++}
++.pity-bar i,
++.progress i {
++ display: block;
++ height: 100%;
++ background: linear-gradient(90deg, var(--cg-gold-2), var(--cg-gold));
++}
++.summon-btns {
++ display: grid;
++ grid-template-columns: 1fr 1fr;
++ gap: 8px;
++}
++.btn-summon {
++ flex-direction: column;
++ min-height: 62px;
++ gap: 0;
++ border-radius: 16px;
++}
++.btn-summon .big {
++ font-size: 18px;
++}
++.reveal {
++ display: grid;
++ grid-template-columns: repeat(5, 1fr);
++ gap: 6px;
++}
++.reveal.one {
++ grid-template-columns: 160px;
++ justify-content: center;
++}
++.reveal-card {
++ --rc: var(--r-common);
++ position: relative;
++ aspect-ratio: 3 / 5;
++ border-radius: 12px;
++ overflow: hidden;
++ border: 2px solid var(--rc);
++ display: grid;
++ align-content: end;
++ text-align: center;
++ padding: 3px;
++ animation: flip 0.5s ease both;
++ background: #241a4d;
++}
++.reveal-card .hero-art {
++ inset: 0 0 34% 0;
++}
++.reveal-card b {
++ font-size: 11px;
++ position: relative;
++}
++.reveal-card small {
++ font-size: 10px;
++ color: var(--cg-dim);
++ position: relative;
++}
++.reveal-card.r-epic,
++.reveal-card.r-legendary {
++ box-shadow: 0 0 18px var(--rc);
++}
++@keyframes flip {
++ from {
++ transform: rotateY(90deg) scale(0.8);
++ opacity: 0;
++ }
++}
++.odds {
++ width: 100%;
++ border-collapse: collapse;
++ font-size: 13px;
++}
++.odds th,
++.odds td {
++ text-align: left;
++ padding: 5px 4px;
++ border-bottom: 1px solid var(--cg-line);
++}
++.odds td:last-child,
++.odds th:last-child {
++ text-align: right;
++ font-variant-numeric: tabular-nums;
++}
++.odds-rules,
++.rules {
++ margin: 0;
++ padding-left: 18px;
++ display: grid;
++ gap: 6px;
++ font-size: 14px;
++}
++
++/* ── Quests ── */
++.calendar {
++ display: grid;
++ grid-template-columns: repeat(4, 1fr);
++ gap: 6px;
++}
++.cal-day {
++ background: rgba(0, 0, 0, 0.3);
++ border: 1px solid var(--cg-line);
++ border-radius: 12px;
++ padding: 6px;
++ display: grid;
++ gap: 4px;
++ align-content: start;
++ min-height: 64px;
++}
++.cal-day small {
++ font-weight: 800;
++ font-size: 11px;
++}
++.cal-day .rw {
++ font-size: 11px;
++ padding: 1px 6px 1px 2px;
++}
++.cal-day .rw-unit {
++ display: none;
++}
++.cal-day.big {
++ grid-column: span 2;
++ background: linear-gradient(135deg, rgba(255, 210, 90, 0.2), rgba(139, 92, 246, 0.25));
++}
++.cal-day.today {
++ border-color: var(--cg-gold);
++ box-shadow: 0 0 0 1px var(--cg-gold);
++}
++.cal-day.done {
++ opacity: 0.45;
++}
++.quest {
++ display: flex;
++ align-items: center;
++ gap: 10px;
++ padding: 8px 0;
++ border-top: 1px solid var(--cg-line);
++}
++.quest-text {
++ flex: 1;
++ display: grid;
++ gap: 5px;
++ min-width: 0;
++}
++.quest-text b {
++ font-size: 14px;
++}
++.quest.claimed {
++ opacity: 0.5;
++}
++.quest.ready .quest-text b {
++ color: var(--cg-gold);
++}
++
++/* ── Shop ── */
++.shop-head {
++ grid-template-columns: auto 1fr;
++ align-items: center;
++}
++.ico.big {
++ width: 40px;
++ height: 40px;
++}
++.shop-head small {
++ font-size: 12px;
++}
++.shop-grid {
++ display: grid;
++ grid-template-columns: 1fr 1fr;
++ gap: 8px;
++}
++.product {
++ background: var(--cg-card);
++ border: 1px solid var(--cg-line);
++ border-radius: 14px;
++ padding: 10px;
++ display: grid;
++ gap: 6px;
++ align-content: start;
++}
++.product h3 {
++ font-size: 14px;
++}
++.product p {
++ font-size: 12px;
++}
++.product-foot {
++ display: flex;
++ align-items: center;
++ justify-content: space-between;
++ gap: 6px;
++ margin-top: auto;
++}
++.product-foot small {
++ font-size: 11px;
++}
++.product.sold {
++ opacity: 0.55;
++}
++
++/* ── Stage list, board, ledger ── */
++.stage-list,
++.ledger {
++ display: grid;
++ gap: 6px;
++}
++.stage-row {
++ display: grid;
++ grid-template-columns: 1fr auto;
++ gap: 4px 8px;
++ align-items: center;
++ background: var(--cg-card);
++ border-radius: 12px;
++ padding: 8px 10px;
++}
++.stage-row > div:first-child {
++ display: grid;
++}
++.stage-row small {
++ font-size: 12px;
++}
++.stage-row.next {
++ border: 1px solid var(--cg-gold);
++}
++.stage-ctl {
++ grid-column: 1 / -1;
++ display: flex;
++ gap: 6px;
++ justify-content: flex-end;
++}
++.board {
++ margin: 0;
++ padding: 0;
++ list-style: none;
++ display: grid;
++ gap: 4px;
++}
++.board li {
++ display: grid;
++ grid-template-columns: 44px 1fr auto;
++ grid-template-rows: auto auto;
++ column-gap: 8px;
++ background: var(--cg-card);
++ border-radius: 10px;
++ padding: 6px 10px;
++ font-size: 14px;
++}
++.board li .rank {
++ grid-row: span 2;
++ align-self: center;
++ font-weight: 900;
++ color: var(--cg-gold);
++}
++.board li small {
++ grid-column: 2 / -1;
++}
++.board li.me {
++ border: 1px solid var(--cg-gold);
++}
++.ledger-row {
++ display: grid;
++ background: var(--cg-card);
++ border-radius: 10px;
++ padding: 6px 10px;
++ font-size: 14px;
++}
++.ledger-row .plus {
++ color: #9ff0a8;
++ font-weight: 700;
++}
++.ledger-row .minus {
++ color: #ffb4a8;
++ font-weight: 700;
++}
++.ledger-row small {
++ font-size: 11px;
++}
++.who p {
++ font-size: 14px;
++}
++
++/* ── Battle ── */
++.battle {
++ position: absolute;
++ inset: 0;
++ z-index: 20;
++ display: flex;
++ flex-direction: column;
++ background:
++ linear-gradient(180deg, rgba(8, 5, 25, 0.35), rgba(8, 5, 25, 0.85)),
++ linear-gradient(170deg, var(--sky-a, #3a2378), var(--sky-b, #120c2a));
++ animation: fade 0.2s ease;
++}
++.battle-head {
++ display: flex;
++ align-items: center;
++ gap: 8px;
++ padding: 10px 12px;
++ background: rgba(0, 0, 0, 0.35);
++}
++.battle-head > div:first-child {
++ flex: 1;
++ display: grid;
++ line-height: 1.15;
++}
++.battle-head small {
++ color: var(--cg-dim);
++ font-size: 12px;
++}
++.round {
++ font-weight: 900;
++ font-size: 14px;
++ color: var(--cg-gold);
++}
++.battle-ctl {
++ display: flex;
++ gap: 4px;
++}
++.field {
++ position: relative;
++ flex: 1;
++ display: grid;
++ grid-template-rows: 1fr auto 1fr;
++ padding: 10px 6px 14px;
++ background:
++ radial-gradient(60% 30% at 50% 30%, rgba(255, 255, 255, 0.12), transparent 70%),
++ linear-gradient(180deg, transparent 55%, rgba(0, 0, 0, 0.25) 56%, rgba(0, 0, 0, 0.45));
++}
++.side {
++ display: flex;
++ justify-content: center;
++ align-items: center;
++ gap: 4px;
++ flex-wrap: wrap;
++}
++.vs {
++ text-align: center;
++ font-weight: 900;
++ font-size: 18px;
++ color: rgba(255, 255, 255, 0.25);
++ letter-spacing: 0.3em;
++}
++.unit {
++ width: 30%;
++ max-width: 116px;
++ display: grid;
++ gap: 3px;
++ justify-items: center;
++ transition:
++ transform 0.16s ease,
++ filter 0.3s,
++ opacity 0.3s;
++}
++.unit-art {
++ width: 100%;
++ aspect-ratio: 1;
++ border-radius: 12px;
++ overflow: hidden;
++ position: relative;
++}
++.unit:not(.foe) .unit-art {
++ border: 2px solid var(--rc, var(--r-common));
++ aspect-ratio: 4 / 5;
++}
++.unit.foe .unit-art {
++ filter: drop-shadow(0 6px 4px rgba(0, 0, 0, 0.45));
++}
++.side:has(.unit:nth-child(4)) .unit {
++ width: 23%;
++}
++.side:has(.unit:nth-child(5)) .unit {
++ width: 18.6%;
++}
++.side .unit.boss {
++ width: 36%;
++ max-width: 150px;
++}
++.unit-art::after {
++ content: '';
++ position: absolute;
++ left: 10%;
++ right: 10%;
++ bottom: -6px;
++ height: 10px;
++ border-radius: 50%;
++ background: rgba(0, 0, 0, 0.35);
++ filter: blur(3px);
++ z-index: -1;
++}
++.unit .enemy {
++ width: 100%;
++ height: 100%;
++}
++.bars {
++ width: 100%;
++ display: grid;
++ gap: 2px;
++}
++.bar {
++ position: relative;
++ display: block;
++ height: 7px;
++ border-radius: 4px;
++ background: rgba(0, 0, 0, 0.6);
++ overflow: hidden;
++}
++.bar .hp {
++ position: absolute;
++ inset: 0 auto 0 0;
++ background: linear-gradient(180deg, #7dff9b, #22c55e);
++ transition: width 0.25s ease;
++}
++.bar .hp.low {
++ background: linear-gradient(180deg, #ff9b9b, #ef4444);
++}
++.bar .shield {
++ position: absolute;
++ inset: 0 auto 0 0;
++ background: rgba(125, 211, 252, 0.7);
++ transition: width 0.25s ease;
++}
++.bar-en {
++ height: 4px;
++}
++.bar-en .en {
++ position: absolute;
++ inset: 0 auto 0 0;
++ background: #ffd25a;
++ transition: width 0.25s ease;
++}
++.unit-name {
++ font-size: 10px;
++ font-weight: 800;
++ text-shadow: 0 1px 2px #000;
++ white-space: nowrap;
++ max-width: 100%;
++ overflow: hidden;
++ text-overflow: ellipsis;
++}
++.side-a .unit.act {
++ transform: translateY(-18px) scale(1.04);
++}
++.side-d .unit.act {
++ transform: translateY(18px) scale(1.04);
++}
++.unit.cast {
++ transform: scale(1.12);
++ filter: drop-shadow(0 0 12px #ffd25a) brightness(1.2);
++}
++.unit.hit .unit-art {
++ animation: shake 0.3s ease;
++}
++.unit.ko {
++ filter: grayscale(1) brightness(0.5);
++ opacity: 0.45;
++}
++@keyframes shake {
++ 25% {
++ transform: translateX(-4px) rotate(-2deg);
++ filter: brightness(1.8);
++ }
++ 75% {
++ transform: translateX(4px) rotate(2deg);
++ }
++}
++.fx-layer {
++ position: absolute;
++ inset: 0;
++ pointer-events: none;
++ overflow: hidden;
++}
++.float {
++ position: absolute;
++ transform: translate(-50%, 0);
++ font-weight: 900;
++ font-size: 17px;
++ color: #fff;
++ text-shadow:
++ 0 2px 0 #000,
++ 0 0 6px rgba(0, 0, 0, 0.8);
++ animation: floatUp 1.05s ease-out forwards;
++ white-space: nowrap;
++}
++.float.crit {
++ font-size: 23px;
++ color: #ffe066;
++}
++.float.adv {
++ color: #ffb86b;
++}
++.float.weak {
++ font-size: 14px;
++ color: #c9c4d9;
++}
++.float.heal {
++ color: #7dff9b;
++}
++.float.shield-up {
++ color: #7dd3fc;
++ font-size: 14px;
++}
++.float.buff {
++ color: #ffd25a;
++ font-size: 13px;
++}
++@keyframes floatUp {
++ from {
++ opacity: 0;
++ transform: translate(-50%, 6px) scale(0.7);
++ }
++ 20% {
++ opacity: 1;
++ transform: translate(-50%, -6px) scale(1.15);
++ }
++ to {
++ opacity: 0;
++ transform: translate(-50%, -40px) scale(1);
++ }
++}
++.skill-banner {
++ position: absolute;
++ left: 0;
++ right: 0;
++ top: 45%;
++ text-align: center;
++ font-size: 30px;
++ font-weight: 900;
++ font-style: italic;
++ letter-spacing: 0.02em;
++ opacity: 0;
++ pointer-events: none;
++ background: linear-gradient(180deg, #fff6c8, #ffd25a 50%, #ff9f1c);
++ -webkit-background-clip: text;
++ background-clip: text;
++ color: transparent;
++ filter: drop-shadow(0 3px 0 #5a2a00) drop-shadow(0 0 12px rgba(0, 0, 0, 0.6));
++}
++.skill-banner.side-d {
++ background: linear-gradient(180deg, #ffd1d1, #ff6b6b 50%, #b91c1c);
++ -webkit-background-clip: text;
++ background-clip: text;
++}
++.skill-banner.on {
++ animation: bannerIn 0.6s ease forwards;
++}
++@keyframes bannerIn {
++ from {
++ opacity: 0;
++ transform: scale(1.6);
++ }
++ 30% {
++ opacity: 1;
++ transform: scale(1);
++ }
++ to {
++ opacity: 0.9;
++ }
++}
++.result {
++ position: absolute;
++ left: 12px;
++ right: 12px;
++ bottom: 16px;
++ z-index: 3;
++ border-radius: 20px;
++ padding: 16px;
++ display: grid;
++ gap: 10px;
++ justify-items: center;
++ text-align: center;
++ background: linear-gradient(180deg, #2a1d5c, #1a1240);
++ border: 1px solid rgba(255, 255, 255, 0.2);
++ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
++ animation: rise 0.25s ease;
++}
++.result h2 {
++ font-size: 34px;
++ font-weight: 900;
++ letter-spacing: 0.04em;
++}
++.result.win h2 {
++ background: linear-gradient(180deg, #fff6c8, #ffd25a 50%, #ff9f1c);
++ -webkit-background-clip: text;
++ background-clip: text;
++ color: transparent;
++ filter: drop-shadow(0 3px 0 #5a2a00);
++}
++.result.lose h2 {
++ color: #ffb4a8;
++}
++.result .stars .star {
++ width: 34px;
++ height: 34px;
++}
++.result .stars .star.on {
++ animation: pop 0.4s ease both;
++}
++.result .stars .star.on:nth-child(2) {
++ animation-delay: 0.15s;
++}
++.result .stars .star.on:nth-child(3) {
++ animation-delay: 0.3s;
++}
++@keyframes pop {
++ from {
++ transform: scale(0);
++ }
++ 70% {
++ transform: scale(1.3);
++ }
++}
++.result .rewards {
++ justify-content: center;
++}
++.result-actions {
++ display: flex;
++ gap: 8px;
++ flex-wrap: wrap;
++ justify-content: center;
++}
++
++/* ── Toasts ── */
++#cg-toasts {
++ position: fixed;
++ left: 50%;
++ top: 70px;
++ transform: translateX(-50%);
++ z-index: 1000;
++ display: grid;
++ gap: 6px;
++ width: min(92vw, 420px);
++ pointer-events: none;
++}
++.toast {
++ background: rgba(20, 14, 48, 0.95);
++ color: #f4f1ff;
++ border: 1px solid rgba(255, 255, 255, 0.18);
++ border-radius: 12px;
++ padding: 10px 14px;
++ font:
++ 600 14px/1.35 system-ui,
++ sans-serif;
++ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
++ animation: rise 0.2s ease;
++ transition: opacity 0.4s;
++}
++.toast-good {
++ border-color: #22c55e;
++}
++.toast-bad {
++ border-color: #ef4444;
++}
++.toast.out {
++ opacity: 0;
++}
++
++@media (prefers-reduced-motion: reduce) {
++ .cg *,
++ .toast {
++ animation: none !important;
++ transition: none !important;
++ }
++}
ddiff --git a/apps/client/src/casual/dom.ts b/apps/client/src/casual/dom.ts
nnew file mode 100644
iindex 0000000..5ecf348
---- /dev/null
++++ b/apps/client/src/casual/dom.ts
@@@ -0,0 +1,156 @@
++/** Small DOM helpers for the casual page: elements from data (never HTML strings), timers, toasts and sheets. */
++
++type Child = Node | string | number | null | undefined | false
++type Props = {
++ class?: string
++ text?: string
++ title?: string
++ attrs?: Record<string, string>
++ style?: Record<string, string>
++ on?: { [K in keyof HTMLElementEventMap]?: (e: HTMLElementEventMap[K]) => void }
++}
++
++export function h<K extends keyof HTMLElementTagNameMap>(
++ tag: K,
++ props: Props | null = null,
++ ...children: Child[]
++): HTMLElementTagNameMap[K] {
++ const el = document.createElement(tag)
++ if (props) {
++ if (props.class) el.className = props.class
++ if (props.text !== undefined) el.textContent = props.text
++ if (props.title) el.title = props.title
++ for (const [k, v] of Object.entries(props.attrs ?? {})) el.setAttribute(k, v)
++ for (const [k, v] of Object.entries(props.style ?? {})) el.style.setProperty(k, v)
++ for (const [k, fn] of Object.entries(props.on ?? {}))
++ el.addEventListener(k, fn as EventListener)
++ }
++ for (const c of children) {
++ if (c === null || c === undefined || c === false) continue
++ el.append(typeof c === 'number' ? String(c) : c)
++ }
++ return el
++}
++
++export function button(
++ label: Child | Child[],
++ onClick: () => void,
++ cls = 'btn',
++ attrs: Record<string, string> = {},
++): HTMLButtonElement {
++ const b = h('button', { class: cls, attrs: { type: 'button', ...attrs } })
++ for (const c of Array.isArray(label) ? label : [label]) {
++ if (c !== null && c !== undefined && c !== false)
++ b.append(typeof c === 'number' ? String(c) : c)
++ }
++ b.addEventListener('click', () => {
++ if (!b.disabled) onClick()
++ })
++ return b
++}
++
++/** 1234 → "1,234"; 1_234_567 → "1.23M". */
++export function num(n: number): string {
++ if (n >= 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`
++ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
++ if (n >= 100_000) return `${Math.round(n / 1000)}K`
++ return n.toLocaleString('en-US')
++}
++
++/** "3h 05m", "4m 09s", "12s". */
++export function duration(ms: number): string {
++ const s = Math.max(0, Math.ceil(ms / 1000))
++ const d = Math.floor(s / 86400)
++ const hr = Math.floor((s % 86400) / 3600)
++ const m = Math.floor((s % 3600) / 60)
++ const sec = s % 60
++ if (d) return `${d}d ${hr}h`
++ if (hr) return `${hr}h ${String(m).padStart(2, '0')}m`
++ if (m) return `${m}m ${String(sec).padStart(2, '0')}s`
++ return `${sec}s`
++}
++
++// ── Ticking text: countdowns re-render once a second while their element is on the page. ──
++const ticking = new Map<Element, () => string>()
++let ticker: number | null = null
++
++export function tick(el: Element, text: () => string): Element {
++ el.textContent = text()
++ ticking.set(el, text)
++ if (ticker === null) {
++ ticker = window.setInterval(() => {
++ for (const [e, fn] of ticking) {
++ if (!e.isConnected) ticking.delete(e)
++ else e.textContent = fn()
++ }
++ }, 1000)
++ }
++ return el
++}
++
++// ── Toasts ──
++export function toast(text: string, kind: 'info' | 'good' | 'bad' = 'info'): void {
++ let box = document.getElementById('cg-toasts')
++ if (!box) {
++ box = h('div', { attrs: { id: 'cg-toasts', role: 'status', 'aria-live': 'polite' } })
++ document.body.append(box)
++ }
++ const t = h('div', { class: `toast toast-${kind}`, text })
++ box.append(t)
++ window.setTimeout(() => t.classList.add('out'), 2600)
++ window.setTimeout(() => t.remove(), 3100)
++}
++
++// ── Bottom sheets (modal) ──
++export interface Sheet {
++ el: HTMLElement
++ body: HTMLElement
++ close(): void
++}
++
++export function sheet(title: string, opts: { wide?: boolean; onClose?: () => void } = {}): Sheet {
++ const root = document.getElementById('cg') ?? document.body
++ const back = h('div', { class: 'sheet-back' })
++ const titleId = `sheet-${Math.random().toString(36).slice(2, 8)}`
++ const body = h('div', { class: 'sheet-body' })
++ let closeBtn: HTMLButtonElement
++ const panel = h(
++ 'div',
++ {
++ class: `sheet${opts.wide ? ' sheet-wide' : ''}`,
++ attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId },
++ },
++ h(
++ 'div',
++ { class: 'sheet-head' },
++ h('h2', { text: title, attrs: { id: titleId } }),
++ (closeBtn = h('button', {
++ class: 'icon-btn',
++ attrs: { type: 'button', 'aria-label': 'Close' },
++ text: '✕',
++ })),
++ ),
++ body,
++ )
++ const before = document.activeElement as HTMLElement | null
++ const close = () => {
++ back.classList.add('out')
++ panel.classList.add('out')
++ window.setTimeout(() => {
++ back.remove()
++ panel.remove()
++ }, 180)
++ document.removeEventListener('keydown', onKey)
++ before?.focus?.()
++ opts.onClose?.()
++ }
++ const onKey = (e: KeyboardEvent) => {
++ if (e.key === 'Escape') close()
++ }
++ closeBtn.addEventListener('click', close)
++ back.addEventListener('click', close)
++ document.addEventListener('keydown', onKey)
++ root.append(back, panel)
++ window.setTimeout(() => closeBtn.focus(), 30)
++ return { el: panel, body, close }
++}
ddiff --git a/apps/client/src/casual/main.ts b/apps/client/src/casual/main.ts
nnew file mode 100644
iindex 0000000..5c3acc3
---- /dev/null
++++ b/apps/client/src/casual/main.ts
@@@ -0,0 +1,112 @@
++/**
++ * The casual games' page (plan T22): /play/<game>. It starts from an ad or a shared link with no sign-in wall: the
++ * browser's Games guest token is the identity until the player signs in with the navbar, and the save moves into
++ * their account then. The link's `src` and `c` (the campaign) go to the server once, with the first visit.
++ */
++import { CASUAL_GAMES, idlePending, packDigest } from '@openvibe/casual'
++import './casual.css'
++import { App } from './app.js'
++import { ApiError, CasualApi } from './api.js'
++import { button, duration, h, num, sheet } from './dom.js'
++import { getIdentity, sessionToken } from '../net/identity.js'
++import { homeView } from './views/home.js'
++import { heroesView } from './views/heroes.js'
++import { summonView } from './views/summon.js'
++import { questsView, shopView } from './views/quests.js'
++import { openMenu } from './views/menu.js'
++import { rewardChips } from './views/parts.js'
++
++async function boot(): Promise<void> {
++ const root = document.getElementById('cg')
++ if (!root) return
++ const id = document.body.dataset.game ?? location.pathname.split('/')[2] ?? ''
++ const pack = CASUAL_GAMES[id]
++ if (!pack) {
++ root.replaceChildren(h('p', { class: 'boot-msg', text: 'This game does not exist.' }))
++ return
++ }
++ const params = new URLSearchParams(location.search)
++ const src = params.get('src')
++ const campaign = params.get('c')
++ if (src || campaign) {
++ // The campaign is recorded with the first visit; a shared or bookmarked link stays clean.
++ params.delete('src')
++ params.delete('c')
++ const rest = params.toString()
++ history.replaceState(null, '', location.pathname + (rest ? `?${rest}` : '') + location.hash)
++ }
++ const api = new CasualApi(pack.id, packDigest(pack), {
++ guest: getIdentity().token,
++ bearer: sessionToken(),
++ })
++ let session
++ try {
++ session = await api.session(src, campaign)
++ } catch (err) {
++ if (err instanceof ApiError && err.code === 'session_expired') {
++ api.dropBearer()
++ session = await api.session(src, campaign).catch(() => null)
++ }
++ if (!session) {
++ root.replaceChildren(
++ h(
++ 'div',
++ { class: 'boot-msg' },
++ h('p', { text: err instanceof Error ? err.message : 'The game could not start.' }),
++ button('Try again', () => location.reload(), 'btn btn-gold'),
++ ),
++ )
++ return
++ }
++ }
++ const app = new App(root, pack, api, session.state, session.version, session.player, {
++ home: homeView,
++ heroes: heroesView,
++ summon: summonView,
++ quests: questsView,
++ shop: shopView,
++ })
++ app.onMenu(openMenu)
++ root.classList.add('ready')
++ app.render()
++ welcome(app, session.created)
++ // Coming back to the tab: catch up the timers and the red dots.
++ document.addEventListener('visibilitychange', () => {
++ if (document.visibilityState === 'visible' && !app.busy) app.render()
++ })
++}
++
++/** First visit: a short hello. Returning after a while: what the idle chest earned meanwhile. */
++function welcome(app: App, created: boolean): void {
++ if (created) {
++ const s = sheet(`Welcome to ${app.pack.name}`)
++ s.body.append(
++ h('p', { text: app.pack.tagline }),
++ h('p', {
++ class: 'muted',
++ text: 'Four heroes are ready. Win the first stages, fuse new heroes from shards and spend your ten free sigils on a summon.',
++ }),
++ button('Let’s go', () => s.close(), 'btn btn-gold btn-wide'),
++ )
++ return
++ }
++ const p = idlePending(app.pack, app.state, app.now())
++ if (p.minutes < 30) return
++ const s = sheet('While you were away')
++ s.body.append(
++ h('p', {
++ text: `Your heroes kept working for ${duration(p.minutes * 60_000)}${p.full ? ' (the chest is full)' : ''}.`,
++ }),
++ rewardChips(app.pack, { gold: p.gold, gems: p.gems }),
++ button(
++ `Collect ${num(p.gold)} gold`,
++ async () => {
++ s.close()
++ await app.run({ t: 'collect' })
++ },
++ 'btn btn-gold btn-wide',
++ ),
++ )
++}
++
++void boot()
ddiff --git a/apps/client/src/casual/views/battle.ts b/apps/client/src/casual/views/battle.ts
nnew file mode 100644
iindex 0000000..4317833
---- /dev/null
++++ b/apps/client/src/casual/views/battle.ts
@@@ -0,0 +1,296 @@
++/**
++ * Plays back a battle the server already fought. The log says who acted and what each hit did; this only shows
++ * it: lunges, numbers, bars and knock-outs, at 1×, 2× or 3×, or skipped to the end.
++ */
++import {
++ heroDef,
++ type BattleEvent,
++ type BattleLog,
++ type HeroClass,
++ type Reward,
++} from '@openvibe/casual'
++import { enemyArt, heroPortrait, icon } from '../art.js'
++import { button, h, num } from '../dom.js'
++import type { App } from '../app.js'
++import { rewardChips, stars } from './parts.js'
++
++const ENEMY_SKILL: Record<HeroClass, string> = {
++ guardian: 'Stone Ward',
++ blade: 'Rend',
++ arcanist: 'Hex Burst',
++ ranger: 'Volley',
++ mender: 'Mend',
++ herald: 'War Cry',
++}
++
++interface UnitView {
++ el: HTMLElement
++ hp: HTMLElement
++ shieldBar: HTMLElement
++ energyBar: HTMLElement
++ maxHp: number
++ cur: number
++ shield: number
++ energy: number
++ side: 'a' | 'd'
++ skill: string
++ cls: HeroClass
++}
++
++function readSpeed(): number {
++ try {
++ const v = Number(localStorage.getItem('cg.speed'))
++ return v === 2 || v === 3 ? v : 1
++ } catch {
++ return 1
++ }
++}
++
++function saveSpeed(v: number): void {
++ try {
++ localStorage.setItem('cg.speed', String(v))
++ } catch {
++ /* fine */
++ }
++}
++
++export interface BattleEnd {
++ next?: () => void
++}
++
++/** Fights `stage` on the server, then plays it back. Resolves when the player leaves the result screen. */
++export async function playBattle(app: App, stage: number): Promise<void> {
++ const before = app.state
++ const answer = await app.run({ t: 'battle', stage }, { quiet: true })
++ if (!answer) return
++ const result = answer.result as {
++ battle: BattleLog
++ won: boolean
++ stars: number
++ first: boolean
++ reward: Reward
++ }
++ const pack = app.pack
++ const def = pack.stages[stage - 1]
++ const chapter = pack.chapters.find((c) => c.index === def?.chapter)
++ const overlay = h('div', {
++ class: 'battle',
++ attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-label': `Battle: stage ${stage}` },
++ style: chapter ? { '--sky-a': chapter.sky[0], '--sky-b': chapter.sky[1] } : {},
++ })
++ const round = h('span', { class: 'round', text: 'Round 1' })
++ let speed = readSpeed()
++ let skipped = false
++ const speedBtn = button(
++ `${speed}×`,
++ () => {
++ speed = speed === 3 ? 1 : speed + 1
++ speedBtn.textContent = `${speed}×`
++ saveSpeed(speed)
++ },
++ 'btn btn-small',
++ { 'aria-label': 'Battle speed' },
++ )
++ const skipBtn = button(
++ 'Skip',
++ () => {
++ skipped = true
++ },
++ 'btn btn-small',
++ )
++ const foes = h('div', { class: 'side side-d' })
++ const team = h('div', { class: 'side side-a' })
++ const fx = h('div', { class: 'fx-layer', attrs: { 'aria-hidden': 'true' } })
++ const banner = h('div', { class: 'skill-banner', attrs: { 'aria-hidden': 'true' } })
++ overlay.append(
++ h(
++ 'div',
++ { class: 'battle-head' },
++ h('div', null, h('b', { text: `Stage ${stage}` }), h('small', { text: chapter?.name ?? '' })),
++ round,
++ h('div', { class: 'battle-ctl' }, speedBtn, skipBtn),
++ ),
++ h('div', { class: 'field' }, foes, h('div', { class: 'vs', text: 'VS' }), team, fx, banner),
++ )
++ app.root.append(overlay)
++
++ const units = new Map<string, UnitView>()
++ for (const u of result.battle.units) {
++ const side = u.r.startsWith('a') ? 'a' : 'd'
++ const hero = side === 'a' ? heroDef(pack, u.id) : undefined
++ const enemy = side === 'd' ? pack.enemies.find((e) => e.id === u.id) : undefined
++ const art = hero ? heroPortrait(hero, true) : enemy ? enemyArt(enemy) : h('span')
++ const hp = h('i', { class: 'hp' })
++ const shieldBar = h('i', { class: 'shield' })
++ const energyBar = h('i', { class: 'en' })
++ const el = h(
++ 'div',
++ {
++ class: `unit ${side === 'a' ? `r-${hero?.rarity ?? 'common'}` : 'foe'}${u.boss ? ' boss' : ''}`,
++ attrs: { 'data-r': u.r },
++ },
++ h('div', { class: `unit-art${hero ? ` el-bg-${hero.element}` : ''}` }, art),
++ h(
++ 'div',
++ { class: 'bars' },
++ h('span', { class: 'bar' }, hp, shieldBar),
++ h('span', { class: 'bar bar-en' }, energyBar),
++ ),
++ h('span', {
++ class: 'unit-name',
++ text: hero ? (hero.name.split(' ')[0] ?? hero.name) : (enemy?.name ?? ''),
++ }),
++ )
++ ;(side === 'a' ? team : foes).append(el)
++ units.set(u.r, {
++ el,
++ hp,
++ shieldBar,
++ energyBar,
++ maxHp: u.hp,
++ cur: u.hp,
++ shield: 0,
++ energy: 0,
++ side,
++ skill: hero ? hero.skill.name : enemy ? ENEMY_SKILL[enemy.class] : '',
++ cls: hero ? hero.class : (enemy?.class ?? 'blade'),
++ })
++ }
++ const draw = (u: UnitView) => {
++ u.hp.style.width = `${(u.cur / u.maxHp) * 100}%`
++ u.hp.classList.toggle('low', u.cur / u.maxHp < 0.3)
++ u.shieldBar.style.width = `${Math.min(100, (u.shield / u.maxHp) * 100)}%`
++ u.energyBar.style.width = `${u.energy}%`
++ u.el.classList.toggle('ko', u.cur <= 0)
++ }
++ units.forEach(draw)
++
++ const wait = (ms: number) =>
++ new Promise<void>((r) => window.setTimeout(r, skipped ? 0 : ms / speed))
++ const float = (target: UnitView, text: string, cls: string) => {
++ if (skipped) return
++ const rect = target.el.getBoundingClientRect()
++ const host = fx.getBoundingClientRect()
++ const n = h('span', {
++ class: `float ${cls}`,
++ text,
++ style: {
++ left: `${rect.left - host.left + rect.width / 2}px`,
++ top: `${rect.top - host.top + rect.height * 0.3}px`,
++ },
++ })
++ fx.append(n)
++ window.setTimeout(() => n.remove(), 1100)
++ }
++
++ const apply = (ev: BattleEvent) => {
++ const actor = units.get(ev.actor)
++ if (!actor) return
++ if (ev.kind === 'attack') actor.energy = Math.min(100, actor.energy + 30)
++ else actor.energy = 0
++ for (const hit of ev.hits) {
++ const t = units.get(hit.r)
++ if (!t) continue
++ if (hit.dmg !== undefined) {
++ t.shield = Math.max(0, t.shield - (hit.blocked ?? 0))
++ t.cur = Math.max(0, t.cur - hit.dmg)
++ t.energy = Math.min(100, t.energy + 10)
++ const text = hit.dmg === 0 && hit.blocked ? `${num(hit.blocked)} blocked` : num(hit.dmg)
++ float(
++ t,
++ hit.crit ? `${text}!` : text,
++ `${hit.crit ? 'crit' : 'dmg'}${hit.adv === 1 ? ' adv' : ''}${hit.adv === -1 ? ' weak' : ''}`,
++ )
++ if (!skipped) {
++ t.el.classList.remove('hit')
++ void t.el.offsetWidth
++ t.el.classList.add('hit')
++ }
++ }
++ if (hit.heal !== undefined) {
++ t.cur = Math.min(t.maxHp, t.cur + hit.heal)
++ if (hit.heal > 0) float(t, `+${num(hit.heal)}`, 'heal')
++ }
++ if (hit.shield !== undefined) {
++ t.shield += hit.shield
++ float(t, `+${num(hit.shield)}`, 'shield-up')
++ }
++ if (ev.kind === 'rally' && t !== actor) {
++ t.energy = Math.min(100, t.energy + 20)
++ float(t, 'ATK ↑', 'buff')
++ }
++ draw(t)
++ }
++ draw(actor)
++ }
++
++ for (const ev of result.battle.events) {
++ if (skipped) {
++ apply(ev)
++ continue
++ }
++ round.textContent = `Round ${ev.round}`
++ const actor = units.get(ev.actor)
++ if (!actor) continue
++ const skill = ev.kind !== 'attack'
++ actor.el.classList.add(skill ? 'cast' : 'act')
++ if (skill) {
++ banner.textContent = actor.skill
++ banner.className = `skill-banner on side-${actor.side}`
++ await wait(380)
++ } else await wait(160)
++ apply(ev)
++ await wait(skill ? 520 : 360)
++ actor.el.classList.remove('act', 'cast')
++ banner.className = 'skill-banner'
++ }
++ units.forEach(draw)
++ round.textContent = `Round ${result.battle.rounds}`
++ skipBtn.disabled = true
++ await wait(300)
++
++ // ── Result ──
++ const next = stage < pack.stages.length && result.won && result.first ? stage + 1 : null
++ const panel = h('div', { class: `result ${result.won ? 'win' : 'lose'}` })
++ panel.append(h('h2', { text: result.won ? 'Victory' : 'Defeat' }))
++ if (result.won) {
++ panel.append(stars(result.stars, 3))
++ panel.append(
++ h('p', { class: 'muted', text: result.first ? 'First clear reward' : 'Replay reward' }),
++ rewardChips(pack, result.reward),
++ )
++ } else {
++ panel.append(
++ h('p', {
++ text:
++ stage > before.stage.cleared
++ ? 'Your team needs to be stronger. Level up heroes, star them up or try another team.'
++ : 'Not this time. Try again with a stronger team.',
++ }),
++ h('p', { class: 'muted', text: 'A lost battle costs 1 energy; the rest came back.' }),
++ )
++ }
++ const actions = h('div', { class: 'result-actions' })
++ panel.append(actions)
++ overlay.append(panel)
++ await new Promise<void>((resolve) => {
++ const leave = (then?: () => void) => {
++ overlay.remove()
++ app.render()
++ resolve()
++ then?.()
++ }
++ if (next !== null)
++ actions.append(
++ button(
++ ['Next stage ', icon('battle')],
++ () => leave(() => void playBattle(app, next)),
++ 'btn btn-gold',
++ ),
++ )
++ if (!result.won)
++ actions.append(button('Heroes', () => leave(() => app.go('heroes')), 'btn btn-gold'))
++ actions.append(button(result.won ? 'Done' : 'Back', () => leave(), 'btn'))
++ window.setTimeout(() => (actions.firstElementChild as HTMLElement | null)?.focus(), 50)
++ })
++}
ddiff --git a/apps/client/src/casual/views/heroes.ts b/apps/client/src/casual/views/heroes.ts
nnew file mode 100644
iindex 0000000..996aad0
---- /dev/null
++++ b/apps/client/src/casual/views/heroes.ts
@@@ -0,0 +1,277 @@
++import {
++ BASE_STARS,
++ heroDef,
++ heroStats,
++ levelCap,
++ levelCost,
++ levelCostFor,
++ power,
++ type GearSlot,
++ type HeroDef,
++} from '@openvibe/casual'
++import { CLASS_NAME, ELEMENT_NAME, RARITY_NAME, elementBadge, heroPortrait, icon } from '../art.js'
++import { button, h, num, sheet, toast } from '../dom.js'
++import type { App } from '../app.js'
++import { gearText, heroCard, heroPower, stars } from './parts.js'
++
++const RANK = { legendary: 0, epic: 1, rare: 2, common: 3 }
++
++export function heroesView(app: App): HTMLElement {
++ const { pack, state: s } = app
++ const owned = pack.heroes
++ .filter((d) => s.heroes[d.id])
++ .sort((a, b) => heroPower(pack, b.id, s.heroes[b.id]!) - heroPower(pack, a.id, s.heroes[a.id]!))
++ const locked = pack.heroes
++ .filter((d) => !s.heroes[d.id])
++ .sort(
++ (a, b) =>
++ (s.shards[b.id] ?? 0) / pack.fuseShards[b.rarity] -
++ (s.shards[a.id] ?? 0) / pack.fuseShards[a.rarity] || RANK[a.rarity] - RANK[b.rarity],
++ )
++ const view = h('div', { class: 'heroes' })
++ const grid = (list: HeroDef[]) => {
++ const g = h('div', { class: 'hero-grid' })
++ for (const d of list) {
++ const hs = s.heroes[d.id]
++ const card = heroCard(pack, d, hs, { shards: s.shards[d.id] ?? 0 })
++ if (s.team.includes(d.id)) card.append(h('span', { class: 'in-team', text: 'Team' }))
++ const ready = hs
++ ? hs.stars < pack.maxStars &&
++ (s.shards[d.id] ?? 0) >= (pack.starShards[hs.stars] ?? Infinity)
++ : (s.shards[d.id] ?? 0) >= pack.fuseShards[d.rarity]
++ if (ready) card.append(h('i', { class: 'dot' }))
++ g.append(
++ button(card, () => heroSheet(app, d.id), 'pick', {
++ 'aria-label': `${d.name}${ready ? ', ready' : ''}`,
++ }),
++ )
++ }
++ return g
++ }
++ view.append(
++ h('div', { class: 'panel-head' }, h('h2', { text: `Your heroes (${owned.length})` })),
++ grid(owned),
++ h('div', { class: 'panel-head' }, h('h2', { text: 'Not yet fused' })),
++ h('p', { class: 'muted section-note', text: 'Shards come from stages, summons and the shop.' }),
++ grid(locked),
++ )
++ return view
++}
++
++export function heroSheet(app: App, id: string): void {
++ const def = heroDef(app.pack, id)
++ if (!def) return
++ const s = sheet(def.name, { wide: true, onClose: () => app.render() })
++ const draw = () => {
++ const { pack, state } = app
++ const hs = state.heroes[id]
++ const shards = state.shards[id] ?? 0
++ s.body.replaceChildren()
++ const head = h(
++ 'div',
++ { class: `hero-head r-${def.rarity}` },
++ h('div', { class: `hero-big el-bg-${def.element}` }, heroPortrait(def)),
++ h(
++ 'div',
++ { class: 'hero-id' },
++ h('small', { class: `rarity r-${def.rarity}`, text: RARITY_NAME[def.rarity] }),
++ h('h3', { text: def.title }),
++ h(
++ 'div',
++ { class: 'tags' },
++ elementBadge(def.element),
++ h('span', { text: `${ELEMENT_NAME[def.element]} · ${CLASS_NAME[def.class]}` }),
++ icon(def.class),
++ ),
++ hs ? stars(hs.stars, pack.maxStars) : stars(BASE_STARS[def.rarity], pack.maxStars),
++ h('p', { class: 'bio', text: def.bio }),
++ ),
++ )
++ s.body.append(head)
++ const show = hs ?? { level: 1, stars: BASE_STARS[def.rarity], gear: {} }
++ const st = heroStats(pack, def, show)
++ s.body.append(
++ h(
++ 'div',
++ { class: 'stats' },
++ ...[
++ ['Power', num(power(st))],
++ ['HP', num(st.hp)],
++ ['ATK', num(st.atk)],
++ ['DEF', num(st.def)],
++ ['SPD', String(st.spd)],
++ ].map(([k, v]) => h('div', null, h('small', { text: k ?? '' }), h('b', { text: v ?? '' }))),
++ ),
++ h(
++ 'div',
++ { class: 'skill' },
++ h('b', { text: def.skill.name }),
++ h('span', { text: def.skill.text }),
++ ),
++ )
++ if (!hs) {
++ const need = pack.fuseShards[def.rarity]
++ const fuse = button(
++ `Fuse (${shards}/${need} shards)`,
++ async () => {
++ if (await app.run({ t: 'fuse', hero: id }, { quiet: true })) {
++ toast(`${def.name} joined your heroes!`, 'good')
++ draw()
++ }
++ },
++ 'btn btn-gold btn-wide',
++ )
++ fuse.disabled = shards < need
++ s.body.append(fuse)
++ return
++ }
++ // Level
++ const cap = levelCap(pack, hs.stars)
++ const levelRow = h('div', { class: 'upgrade' })
++ levelRow.append(
++ h(
++ 'div',
++ null,
++ h('b', { text: `Level ${hs.level} / ${cap}` }),
++ h('small', {
++ class: 'muted',
++ text:
++ hs.level < cap
++ ? `Next level: ${num(levelCost(hs.level))} gold`
++ : 'Star up to raise the cap',
++ }),
++ ),
++ )
++ const lvl = (times: number, label: string) => {
++ const n = Math.min(times, cap - hs.level)
++ const b = button(
++ label,
++ async () => {
++ const a = await app.run({ t: 'level', hero: id, times }, { quiet: true })
++ if (a) draw()
++ },
++ 'btn btn-small',
++ )
++ b.disabled = n <= 0 || state.wallet.gold < levelCost(hs.level)
++ b.title = n > 0 ? `${num(levelCostFor(hs.level, n))} gold for ${n}` : ''
++ return b
++ }
++ levelRow.append(h('div', { class: 'btn-row' }, lvl(1, '+1'), lvl(10, '+10'), lvl(200, 'Max')))
++ s.body.append(levelRow)
++ // Stars
++ if (hs.stars < pack.maxStars) {
++ const needShards = pack.starShards[hs.stars] ?? 0
++ const needGold = pack.starGold[hs.stars] ?? 0
++ const star = button(
++ ['Star up ', icon('star')],
++ async () => {
++ if (await app.run({ t: 'star', hero: id }, { quiet: true })) {
++ toast(`${def.name} reached ${hs.stars + 1} stars`, 'good')
++ draw()
++ }
++ },
++ 'btn btn-gold btn-small',
++ )
++ star.disabled = shards < needShards || state.wallet.gold < needGold
++ s.body.append(
++ h(
++ 'div',
++ { class: 'upgrade' },
++ h(
++ 'div',
++ null,
++ h('b', { text: `Stars ${hs.stars} → ${hs.stars + 1}` }),
++ h('small', {
++ class: 'muted',
++ text: `${shards}/${needShards} shards · ${num(needGold)} gold · level cap ${cap} → ${cap + pack.levelsPerStar}`,
++ }),
++ ),
++ star,
++ ),
++ )
++ }
++ // Gear
++ const slots: GearSlot[] = ['weapon', 'armor', 'charm']
++ const gearRow = h('div', { class: 'gear-row' })
++ for (const slot of slots) {
++ const gid = hs.gear[slot]
++ const g = gid ? pack.gear.find((x) => x.id === gid) : undefined
++ gearRow.append(
++ button(
++ [
++ h('small', { text: slot }),
++ h('b', { text: g ? g.name : 'Empty' }),
++ g ? h('span', { class: 'muted', text: gearText(g.bonus) }) : null,
++ ],
++ () => gearPicker(app, id, slot, draw),
++ `gear-slot${g ? ` r-${g.rarity}` : ''}`,
++ ),
++ )
++ }
++ s.body.append(h('h4', { text: 'Gear' }), gearRow)
++ // Team
++ const inTeam = state.team.includes(id)
++ const teamBtn = button(
++ inTeam ? 'Remove from team' : 'Add to team',
++ async () => {
++ const next = inTeam ? state.team.filter((x) => x !== id) : [...state.team, id]
++ if (!inTeam && next.length > pack.teamSize) {
++ toast(`A team holds ${pack.teamSize}. Remove someone first.`)
++ return
++ }
++ if (await app.run({ t: 'team', heroes: next }, { quiet: true })) draw()
++ },
++ 'btn btn-wide',
++ )
++ if (inTeam && state.team.length === 1) teamBtn.disabled = true
++ s.body.append(teamBtn)
++ }
++ draw()
++}
++
++function gearPicker(app: App, hero: string, slot: GearSlot, done: () => void): void {
++ const s = sheet(`Choose ${slot}`)
++ const hs = app.state.heroes[hero]
++ const list = h('div', { class: 'gear-list' })
++ const own = app.pack.gear.filter((g) => g.slot === slot && (app.state.gear[g.id] ?? 0) > 0)
++ if (hs?.gear[slot]) {
++ list.append(
++ button(
++ 'Take it off',
++ async () => {
++ if (await app.run({ t: 'equip', hero, slot, gear: null }, { quiet: true })) {
++ s.close()
++ done()
++ }
++ },
++ 'btn',
++ ),
++ )
++ }
++ for (const g of own) {
++ list.append(
++ button(
++ [
++ h('b', { text: g.name }),
++ h('span', { class: 'muted', text: `${gearText(g.bonus)} · ×${app.state.gear[g.id]}` }),
++ ],
++ async () => {
++ if (await app.run({ t: 'equip', hero, slot, gear: g.id }, { quiet: true })) {
++ s.close()
++ done()
++ }
++ },
++ `gear-choice r-${g.rarity}`,
++ ),
++ )
++ }
++ if (!own.length && !hs?.gear[slot]) {
++ list.append(
++ h('p', {
++ class: 'muted',
++ text: 'No spare gear for this slot yet. Bosses drop gear every ten stages, and the shop sells some.',
++ }),
++ )
++ }
++ s.body.append(list)
++}
ddiff --git a/apps/client/src/casual/views/home.ts b/apps/client/src/casual/views/home.ts
nnew file mode 100644
iindex 0000000..b5e718d
---- /dev/null
++++ b/apps/client/src/casual/views/home.ts
@@@ -0,0 +1,315 @@
++import {
++ enemyStats,
++ energyNow,
++ goldPerMinute,
++ heroDef,
++ idlePending,
++ nextEnergyAt,
++ teamPower,
++ power,
++ dayKey,
++} from '@openvibe/casual'
++import { enemyArt, icon } from '../art.js'
++import { button, duration, h, num, sheet, tick, toast } from '../dom.js'
++import type { App } from '../app.js'
++import { playBattle } from './battle.js'
++import { heroCard, heroPower, rewardChips, stars } from './parts.js'
++
++export function homeView(app: App): HTMLElement {
++ const { pack, state: s } = app
++ const now = app.now()
++ const next = Math.min(s.stage.cleared + 1, pack.stages.length)
++ const stage = pack.stages[next - 1]!
++ const chapter = pack.chapters.find((c) => c.index === stage.chapter)!
++ const first = next > s.stage.cleared
++ const cost = first && next <= pack.freeFirstClears ? 0 : stage.energy
++ const allClear = s.stage.cleared >= pack.stages.length
++ const view = h('div', { class: 'home' })
++
++ // ── The next stage ──
++ const foes = h('div', { class: 'foes' })
++ for (const id of stage.enemies) {
++ const e = pack.enemies.find((x) => x.id === id)
++ if (e)
++ foes.append(
++ h('div', { class: `foe-mini${e.boss ? ' boss' : ''}`, title: e.name }, enemyArt(e)),
++ )
++ }
++ const enemyPower = stage.enemies.reduce((sum, id) => {
++ const e = pack.enemies.find((x) => x.id === id)
++ return e ? sum + power(enemyStats(e.class, stage.level, stage.index, e.boss)) : sum
++ }, 0)
++ const mine = teamPower(pack, s)
++ const energy = energyNow(pack, s, now)
++ const enough = energy >= cost
++ const battleBtn = button(
++ [
++ h('span', { class: 'big', text: allClear ? 'Replay' : 'Battle' }),
++ h('small', null, cost ? icon('energy') : null, cost ? String(cost) : 'Free'),
++ ],
++ () => void playBattle(app, next),
++ `btn btn-gold btn-battle${s.stage.cleared === 0 ? ' pulse' : ''}`,
++ )
++ if (!enough) {
++ battleBtn.disabled = true
++ const at = nextEnergyAt(pack, s, now)
++ battleBtn
++ .querySelector('small')
++ ?.replaceChildren(
++ tick(
++ h('span'),
++ () =>
++ `Energy in ${duration(Math.max(0, (at ?? now) + (cost - energy - 1) * pack.energy.regenMs - app.now()))}`,
++ ),
++ )
++ }
++ const chapterProgress = ((next - 1) % 10) + (allClear ? 1 : 0)
++ view.append(
++ h(
++ 'article',
++ {
++ class: 'stage-card',
++ style: { '--sky-a': chapter.sky[0], '--sky-b': chapter.sky[1] },
++ },
++ h(
++ 'div',
++ { class: 'stage-head' },
++ h(
++ 'div',
++ null,
++ h('small', { text: `Chapter ${chapter.index}` }),
++ h('h1', { text: chapter.name }),
++ ),
++ button([icon('board'), 'Stages'], () => stageList(app), 'btn btn-small btn-glass'),
++ ),
++ h(
++ 'div',
++ {
++ class: 'chapter-track',
++ attrs: { 'aria-label': `Stage ${next} of ${pack.stages.length}` },
++ },
++ ...Array.from({ length: 10 }, (_, i) =>
++ h('i', {
++ class:
++ i < chapterProgress ? 'done' : i === chapterProgress ? 'here' : i === 9 ? 'boss' : '',
++ }),
++ ),
++ ),
++ foes,
++ h(
++ 'div',
++ { class: 'stage-meta' },
++ h('span', {
++ text: `Stage ${next}${stage.enemies.some((id) => pack.enemies.find((e) => e.id === id)?.boss) ? ' · Boss' : ''}`,
++ }),
++ h('span', { text: `Enemy Lv ${stage.level}` }),
++ h('span', {
++ class: mine >= enemyPower ? 'ok' : 'warn',
++ text: `Power ${num(mine)} vs ${num(enemyPower)}`,
++ }),
++ ),
++ first && !allClear
++ ? h(
++ 'div',
++ { class: 'first-reward' },
++ h('small', { text: 'First clear' }),
++ rewardChips(pack, stage.first),
++ )
++ : null,
++ battleBtn,
++ s.stage.cleared === 0
++ ? h('p', { class: 'hint', text: 'Tap Battle. Your heroes fight on their own.' })
++ : null,
++ ),
++ )
++
++ // ── Team ──
++ const strip = h('div', { class: 'team-strip' })
++ for (let i = 0; i < pack.teamSize; i++) {
++ const id = s.team[i]
++ const def = id ? heroDef(pack, id) : undefined
++ const hs = id ? s.heroes[id] : undefined
++ strip.append(
++ def && hs
++ ? heroCard(pack, def, hs, { small: true })
++ : h('div', { class: 'hero-card empty small', attrs: { 'aria-label': 'Empty slot' } }, '+'),
++ )
++ }
++ view.append(
++ h(
++ 'section',
++ { class: 'panel' },
++ h(
++ 'div',
++ { class: 'panel-head' },
++ h('h2', { text: 'Team' }),
++ h('span', { class: 'muted', text: `Power ${num(mine)}` }),
++ button('Edit', () => teamEditor(app), 'btn btn-small'),
++ ),
++ strip,
++ ),
++ )
++
++ // ── Idle chest ──
++ const idle = h('section', { class: 'panel idle' })
++ const pending = () => idlePending(pack, app.state, app.now())
++ const amount = h('b')
++ tick(amount, () => {
++ const p = pending()
++ return `${num(p.gold)} gold${p.gems ? ` · ${p.gems} gems` : ''}`
++ })
++ const status = h('small', { class: 'muted' })
++ tick(status, () => {
++ const p = pending()
++ if (p.full) return 'Full: collect it to keep earning'
++ return `${num(goldPerMinute(pack, app.state))} gold a minute · full in ${duration(pack.idle.capMs - (app.now() - app.state.idle.at))}`
++ })
++ const collect = button(
++ 'Collect',
++ async () => {
++ const answer = await app.run({ t: 'collect' })
++ if (answer) {
++ const r = answer.result as { gold: number; gems: number; minutes: number }
++ toast(
++ `+${num(r.gold)} gold${r.gems ? `, +${r.gems} gems` : ''} from ${duration(r.minutes * 60_000)} away`,
++ 'good',
++ )
++ }
++ },
++ `btn btn-gold${pending().minutes >= 60 ? ' pulse' : ''}`,
++ )
++ if (pending().minutes < 1) collect.disabled = true
++ idle.append(
++ icon('chest', 'ico chest'),
++ h('div', { class: 'idle-text' }, h('h2', { text: 'Idle chest' }), amount, status),
++ collect,
++ )
++ view.append(idle)
++
++ // ── Login reward ──
++ if (s.login.last !== dayKey(now)) {
++ const day = (s.login.count % pack.calendar.length) + 1
++ view.append(
++ h(
++ 'section',
++ { class: 'panel login' },
++ h(
++ 'div',
++ null,
++ h('h2', { text: `Day ${day} reward` }),
++ rewardChips(pack, pack.calendar[day - 1] ?? {}),
++ ),
++ button(
++ 'Claim',
++ async () => {
++ if (await app.run({ t: 'login' })) toast(`Day ${day} reward claimed`, 'good')
++ },
++ 'btn btn-gold pulse',
++ ),
++ ),
++ )
++ }
++ return view
++}
++
++function teamEditor(app: App): void {
++ const s = sheet('Choose your team', { wide: true, onClose: () => app.render() })
++ let picked = [...app.state.team]
++ const grid = h('div', { class: 'hero-grid' })
++ const count = h('p', { class: 'muted' })
++ const save = button(
++ 'Save team',
++ async () => {
++ if (await app.run({ t: 'team', heroes: picked })) {
++ s.close()
++ toast('Team saved', 'good')
++ }
++ },
++ 'btn btn-gold btn-wide',
++ )
++ const draw = () => {
++ grid.replaceChildren()
++ const owned = Object.entries(app.state.heroes).sort(
++ (a, b) => heroPower(app.pack, b[0], b[1]) - heroPower(app.pack, a[0], a[1]),
++ )
++ for (const [id, hs] of owned) {
++ const def = heroDef(app.pack, id)
++ if (!def) continue
++ const card = heroCard(app.pack, def, hs)
++ const slot = picked.indexOf(id)
++ if (slot >= 0) card.append(h('span', { class: 'slot-no', text: String(slot + 1) }))
++ const b = button(
++ card,
++ () => {
++ if (picked.includes(id)) picked = picked.filter((x) => x !== id)
++ else if (picked.length < app.pack.teamSize) picked.push(id)
++ else toast(`A team holds ${app.pack.teamSize} heroes`)
++ draw()
++ },
++ `pick${slot >= 0 ? ' is-on' : ''}`,
++ { 'aria-pressed': String(slot >= 0), 'aria-label': def.name },
++ )
++ grid.append(b)
++ }
++ count.textContent = `${picked.length} of ${app.pack.teamSize} picked. Guardians stand in front; the rest strike from behind.`
++ save.disabled = picked.length === 0
++ }
++ draw()
++ s.body.append(count, grid, save)
++}
++
++function stageList(app: App): void {
++ const s = sheet('Stages', { wide: true, onClose: () => app.render() })
++ const { pack } = app
++ const list = h('div', { class: 'stage-list' })
++ const upto = Math.min(app.state.stage.cleared + 1, pack.stages.length)
++ for (let i = upto; i >= 1; i--) {
++ const st = pack.stages[i - 1]!
++ const got = app.state.stage.stars[String(i)] ?? 0
++ const row = h(
++ 'div',
++ { class: `stage-row${i === upto && i > app.state.stage.cleared ? ' next' : ''}` },
++ h('div', null, h('b', { text: `Stage ${i}` }), h('small', { class: 'muted', text: st.name })),
++ got
++ ? stars(got, 3)
++ : h('small', { class: 'muted', text: i > app.state.stage.cleared ? 'Not cleared' : '' }),
++ )
++ const ctl = h('div', { class: 'stage-ctl' })
++ ctl.append(
++ button(
++ 'Battle',
++ () => {
++ s.close()
++ void playBattle(app, i)
++ },
++ 'btn btn-small',
++ ),
++ )
++ if (got >= 3) {
++ ctl.append(
++ button(
++ [icon('sweep'), '×3'],
++ async () => {
++ const a = await app.run({ t: 'sweep', stage: i, times: 3 }, { quiet: true })
++ if (a)
++ toast(
++ `Swept stage ${i} three times (${(a.result as { energy: number }).energy} energy)`,
++ 'good',
++ )
++ },
++ 'btn btn-small',
++ { 'aria-label': `Sweep stage ${i} three times` },
++ ),
++ )
++ }
++ row.append(ctl)
++ list.append(row)
++ }
++ s.body.append(
++ h('p', {
++ class: 'muted',
++ text: 'Replays pay gold and one shard of the stage’s hero. Three-star stages can be swept instantly for the same energy.',
++ }),
++ list,
++ )
++}
ddiff --git a/apps/client/src/casual/views/menu.ts b/apps/client/src/casual/views/menu.ts
nnew file mode 100644
iindex 0000000..13a94e4
---- /dev/null
++++ b/apps/client/src/casual/views/menu.ts
@@@ -0,0 +1,175 @@
++import { heroDef } from '@openvibe/casual'
++import { icon } from '../art.js'
++import { button, h, num, sheet } from '../dom.js'
++import type { App } from '../app.js'
++
++export function openMenu(app: App): void {
++ const s = sheet('Vibe Heroes')
++ const p = app.player
++ s.body.append(
++ h(
++ 'div',
++ { class: 'panel who' },
++ p.signedIn
++ ? h(
++ 'p',
++ null,
++ 'Signed in as ',
++ h('b', { text: p.name ?? 'you' }),
++ '. Your heroes are saved to your OpenVibe account on every device.',
++ )
++ : h(
++ 'p',
++ null,
++ h('b', { text: 'Playing as a guest. ' }),
++ 'Your progress lives in this browser. Sign in with the OpenVibe button at the top of the page to keep it on every device; this guest save moves into your account.',
++ ),
++ ),
++ h(
++ 'div',
++ { class: 'menu-list' },
++ button([icon('trophy'), 'Leaderboard'], () => board(app), 'menu-item'),
++ button([icon('quests'), 'Your history'], () => history(app), 'menu-item'),
++ button([icon('summon'), 'How the game works'], () => rules(app), 'menu-item'),
++ ),
++ )
++}
++
++async function board(app: App): Promise<void> {
++ const s = sheet('Leaderboard')
++ s.body.append(
++ h('p', {
++ class: 'muted',
++ text: 'The furthest signed-in players: highest stage, then team power.',
++ }),
++ )
++ const list = h('ol', { class: 'board' })
++ s.body.append(list)
++ try {
++ const { entries } = await app.api.leaderboard()
++ if (!entries.length)
++ list.append(
++ h('li', { class: 'muted', text: 'Nobody yet. Sign in and clear a stage to be first.' }),
++ )
++ for (const e of entries) {
++ list.append(
++ h(
++ 'li',
++ { class: e.name === app.player.name ? 'me' : '' },
++ h('span', { class: 'rank', text: `#${e.rank}` }),
++ h('b', { text: e.name }),
++ h('span', { text: `Stage ${e.stage}` }),
++ h('small', { class: 'muted', text: `Power ${num(e.power)}` }),
++ ),
++ )
++ }
++ } catch (err) {
++ app.fail(err)
++ }
++}
++
++const REASONS: [RegExp, (m: RegExpMatchArray) => string][] = [
++ [/^start$/, () => 'Starting gift'],
++ [/^idle$/, () => 'Idle chest'],
++ [/^stage:(\d+):first$/, (m) => `Stage ${m[1]} first clear`],
++ [/^stage:(\d+)$/, (m) => `Stage ${m[1]} replay`],
++ [/^sweep:(\d+)$/, (m) => `Stage ${m[1]} sweep`],
++ [/^level:(.+)$/, () => 'Level up'],
++ [/^star:(.+)$/, () => 'Star up'],
++ [/^fuse:(.+)$/, () => 'Fuse'],
++ [/^fuse$/, () => 'Fuse'],
++ [/^summon$/, () => 'Summon'],
++ [/^quest:(.+)$/, () => 'Quest reward'],
++ [/^login:(\d+)$/, (m) => `Login day ${m[1]}`],
++ [/^buy:(.+)$/, () => 'Shop'],
++]
++
++function assetName(app: App, asset: string): string {
++ const [kind, id] = asset.split(':')
++ if (kind === 'gold') return 'gold'
++ if (kind === 'gems') return 'gems'
++ if (kind === 'gemsPaid') return 'bought gems'
++ if (kind === 'sigils') return 'sigils'
++ if (kind === 'shard') return `${heroDef(app.pack, id ?? '')?.name ?? id} shards`
++ if (kind === 'hero') return heroDef(app.pack, id ?? '')?.name ?? 'hero'
++ if (kind === 'gear') return app.pack.gear.find((g) => g.id === id)?.name ?? 'gear'
++ return asset
++}
++
++async function history(app: App): Promise<void> {
++ const s = sheet('Your history', { wide: true })
++ s.body.append(
++ h('p', {
++ class: 'muted',
++ text: 'Every change to your currencies and items, newest first, as the server recorded it.',
++ }),
++ )
++ const list = h('div', { class: 'ledger' })
++ s.body.append(list)
++ let before: number | null = null
++ const more = button('Older', () => void load(), 'btn btn-wide')
++ const load = async () => {
++ try {
++ const page = await app.api.ledger(before)
++ for (const l of page.lines) {
++ const rule = REASONS.find(([re]) => re.test(l.reason))
++ const m = rule ? l.reason.match(rule[0]) : null
++ list.append(
++ h(
++ 'div',
++ { class: 'ledger-row' },
++ h('span', {
++ class: l.delta >= 0 ? 'plus' : 'minus',
++ text: `${l.delta >= 0 ? '+' : '−'}${num(Math.abs(l.delta))} ${assetName(app, l.asset)}`,
++ }),
++ h('small', {
++ class: 'muted',
++ text: `${rule && m ? rule[1](m) : l.reason} · ${new Date(l.at).toLocaleString()}`,
++ }),
++ ),
++ )
++ }
++ before = page.next
++ if (before) s.body.append(more)
++ else more.remove()
++ } catch (err) {
++ app.fail(err)
++ }
++ }
++ await load()
++}
++
++function rules(app: App): void {
++ const s = sheet('How the game works', { wide: true })
++ const pk = app.pack
++ s.body.append(
++ h(
++ 'ul',
++ { class: 'rules' },
++ h('li', {
++ text: 'Your team fights on its own. Win stages to earn gold, gems, sigils, hero shards and gear. Each chapter ends with a boss.',
++ }),
++ h('li', {
++ text: `The first ${pk.freeFirstClears} stages are free; after that a battle costs energy, which refills one point every five minutes up to ${pk.energy.max}. A lost battle costs only one.`,
++ }),
++ h('li', {
++ text: 'The idle chest fills with gold (and a gem an hour) while you are away, for up to twelve hours.',
++ }),
++ h('li', {
++ text: `Collect shards to fuse a hero: ${pk.fuseShards.common} common, ${pk.fuseShards.rare} rare, ${pk.fuseShards.epic} epic, ${pk.fuseShards.legendary} legendary. More shards raise a hero’s stars, and each star raises the level cap by ${pk.levelsPerStar}.`,
++ }),
++ h('li', {
++ text: 'Flame beats Grove, Grove beats Tide, Tide beats Flame; Dawn and Dusk each beat the other.',
++ }),
++ h('li', {
++ text: 'Summons use sigils, which are earned in play and never sold. The odds of every hero are published, and a legendary is guaranteed within a fixed number of draws.',
++ }),
++ h('li', {
++ text: 'Everything in the shop lists exactly what you get. Nothing in Vibe Heroes is a paid random draw.',
++ }),
++ h('li', {
++ text: 'Every hero and enemy is an original OpenVibe character, and every hero is an adult.',
++ }),
++ ),
++ )
++}
ddiff --git a/apps/client/src/casual/views/parts.ts b/apps/client/src/casual/views/parts.ts
nnew file mode 100644
iindex 0000000..5ab0f03
---- /dev/null
++++ b/apps/client/src/casual/views/parts.ts
@@@ -0,0 +1,96 @@
++import {
++ heroDef,
++ heroStats,
++ power,
++ type CasualPack,
++ type HeroDef,
++ type HeroState,
++ type Reward,
++} from '@openvibe/casual'
++import { elementBadge, heroPortrait, icon } from '../art.js'
++import { h, num } from '../dom.js'
++
++/** A row of stars, filled up to `n` of `max`. */
++export function stars(n: number, max = n): HTMLElement {
++ const row = h('span', { class: 'stars', attrs: { 'aria-label': `${n} stars` } })
++ for (let i = 0; i < max; i++) row.append(icon('star', i < n ? 'ico star on' : 'ico star'))
++ return row
++}
++
++/** A hero's card: portrait in a rarity frame, element, stars and level (or shard progress when locked). */
++export function heroCard(
++ pack: CasualPack,
++ def: HeroDef,
++ hs: HeroState | undefined,
++ opts: { shards?: number; small?: boolean } = {},
++): HTMLElement {
++ const card = h('div', {
++ class: `hero-card r-${def.rarity}${hs ? '' : ' locked'}${opts.small ? ' small' : ''}`,
++ })
++ const art = h('div', { class: `hero-art el-bg-${def.element}` }, heroPortrait(def, true))
++ card.append(art, elementBadge(def.element))
++ if (hs) {
++ card.append(h('span', { class: 'lv', text: `Lv ${hs.level}` }), stars(hs.stars))
++ } else {
++ const need = pack.fuseShards[def.rarity]
++ const have = opts.shards ?? 0
++ card.append(
++ h(
++ 'span',
++ { class: 'shard-bar', attrs: { 'aria-label': `${have} of ${need} shards` } },
++ h('i', { style: { width: `${Math.min(100, (have / need) * 100)}%` } }),
++ h('b', { text: `${have}/${need}` }),
++ ),
++ )
++ }
++ if (!opts.small)
++ card.append(h('span', { class: 'name', text: def.name.split(' ')[0] ?? def.name }))
++ return card
++}
++
++export function heroPower(pack: CasualPack, id: string, hs: HeroState): number {
++ const def = heroDef(pack, id)
++ return def ? power(heroStats(pack, def, hs)) : 0
++}
++
++/** A reward as chips: gold, gems, sigils, energy, shards and gear. */
++export function rewardChips(pack: CasualPack, r: Reward): HTMLElement {
++ const row = h('div', { class: 'rewards' })
++ const chip = (ico: string | null, text: string, cls = '') =>
++ row.append(h('span', { class: `rw ${cls}` }, ico ? icon(ico) : null, h('b', { text })))
++ if (r.gold) chip('gold', num(r.gold))
++ if (r.gems) chip('gems', num(r.gems))
++ if (r.sigils) chip('sigils', String(r.sigils))
++ if (r.energy) chip('energy', String(r.energy))
++ for (const s of r.shards ?? []) {
++ const def = heroDef(pack, s.hero)
++ if (!def) continue
++ const mini = h('span', { class: `rw rw-shard r-${def.rarity}` })
++ const face = h('span', { class: `rw-face el-bg-${def.element}` }, heroPortrait(def, true))
++ mini.title = `${s.n} ${def.name} shard${s.n === 1 ? '' : 's'}`
++ mini.append(
++ face,
++ h(
++ 'b',
++ null,
++ `${s.n} ${def.name.split(' ')[0]}`,
++ h('span', { class: 'rw-unit', text: ` shard${s.n === 1 ? '' : 's'}` }),
++ ),
++ )
++ row.append(mini)
++ }
++ for (const g of r.gear ?? []) {
++ const def = pack.gear.find((x) => x.id === g)
++ if (def) chip(null, `⚔ ${def.name}`, `r-${def.rarity}`)
++ }
++ return row
++}
++
++export function gearText(bonus: { atk?: number; hp?: number; def?: number; spd?: number }): string {
++ const parts: string[] = []
++ if (bonus.atk) parts.push(`ATK +${bonus.atk}%`)
++ if (bonus.hp) parts.push(`HP +${bonus.hp}%`)
++ if (bonus.def) parts.push(`DEF +${bonus.def}%`)
++ if (bonus.spd) parts.push(`SPD +${bonus.spd}`)
++ return parts.join(' · ')
++}
ddiff --git a/apps/client/src/casual/views/quests.ts b/apps/client/src/casual/views/quests.ts
nnew file mode 100644
iindex 0000000..911b676
---- /dev/null
++++ b/apps/client/src/casual/views/quests.ts
@@@ -0,0 +1,204 @@
++import {
++ boughtThisPeriod,
++ dayKey,
++ productReward,
++ questProgress,
++ resets,
++ type QuestDef,
++} from '@openvibe/casual'
++import { icon } from '../art.js'
++import { button, duration, h, sheet, tick, toast } from '../dom.js'
++import type { App } from '../app.js'
++import { rewardChips } from './parts.js'
++
++export function questsView(app: App): HTMLElement {
++ const { pack, state: s } = app
++ const now = app.now()
++ const view = h('div', { class: 'quests' })
++
++ // ── Login calendar ──
++ const today = s.login.last === dayKey(now)
++ const cycleDay = s.login.count % pack.calendar.length
++ const cal = h('div', { class: 'calendar' })
++ pack.calendar.forEach((r, i) => {
++ const claimed = i < cycleDay || (today && i === cycleDay - 1)
++ const isToday = !today && i === cycleDay
++ const cell = h(
++ 'div',
++ {
++ class: `cal-day${claimed ? ' done' : ''}${isToday ? ' today' : ''}${i === pack.calendar.length - 1 ? ' big' : ''}`,
++ },
++ h('small', { text: `Day ${i + 1}` }),
++ rewardChips(pack, r),
++ )
++ cal.append(cell)
++ })
++ const claim = button(
++ 'Claim today',
++ async () => {
++ if (await app.run({ t: 'login' })) toast('Login reward claimed', 'good')
++ },
++ `btn btn-gold${today ? '' : ' pulse'}`,
++ )
++ claim.disabled = today
++ view.append(
++ h(
++ 'section',
++ { class: 'panel' },
++ h('div', { class: 'panel-head' }, h('h2', { text: 'Login rewards' }), claim),
++ cal,
++ h('p', {
++ class: 'muted',
++ text: 'One reward a day you play. Missing a day never resets the calendar.',
++ }),
++ ),
++ )
++
++ const list = (period: 'daily' | 'weekly') => {
++ const panel = h('section', { class: 'panel' })
++ const until = period === 'daily' ? resets(now).day : resets(now).week
++ const left = h('small', { class: 'muted' })
++ tick(left, () => `Resets in ${duration(until - app.now())}`)
++ panel.append(
++ h(
++ 'div',
++ { class: 'panel-head' },
++ h('h2', { text: period === 'daily' ? 'Daily quests' : 'Weekly quests' }),
++ left,
++ ),
++ )
++ const quests = pack.quests.filter((q) => q.period === period)
++ for (const q of sortQuests(app, quests)) panel.append(questRow(app, q))
++ return panel
++ }
++ view.append(list('daily'), list('weekly'))
++ return view
++}
++
++function sortQuests(app: App, quests: QuestDef[]): QuestDef[] {
++ const now = app.now()
++ const order = (q: QuestDef) => {
++ const p = questProgress(app.state, q, now)
++ return p.claimed ? 2 : p.n >= q.target ? 0 : 1
++ }
++ return [...quests].sort((a, b) => order(a) - order(b))
++}
++
++function questRow(app: App, q: QuestDef): HTMLElement {
++ const p = questProgress(app.state, q, app.now())
++ const done = p.n >= q.target
++ const row = h(
++ 'div',
++ { class: `quest${p.claimed ? ' claimed' : done ? ' ready' : ''}` },
++ h(
++ 'div',
++ { class: 'quest-text' },
++ h('b', { text: q.text }),
++ h(
++ 'span',
++ { class: 'progress', attrs: { 'aria-label': `${Math.min(p.n, q.target)} of ${q.target}` } },
++ h('i', { style: { width: `${Math.min(100, (p.n / q.target) * 100)}%` } }),
++ ),
++ rewardChips(app.pack, q.reward),
++ ),
++ )
++ const b = button(
++ p.claimed ? 'Done' : done ? 'Claim' : `${Math.min(p.n, q.target)}/${q.target}`,
++ async () => {
++ if (await app.run({ t: 'quest', quest: q.id })) toast('Quest reward claimed', 'good')
++ },
++ `btn btn-small${done && !p.claimed ? ' btn-gold' : ''}`,
++ )
++ b.disabled = p.claimed || !done
++ row.append(b)
++ return row
++}
++
++export function shopView(app: App): HTMLElement {
++ const { pack, state: s } = app
++ const now = app.now()
++ const view = h('div', { class: 'shop' })
++ view.append(
++ h(
++ 'div',
++ { class: 'panel shop-head' },
++ icon('gems', 'ico big'),
++ h(
++ 'div',
++ null,
++ h('h2', { text: `${s.wallet.gems + s.wallet.gemsPaid} gems` }),
++ h('small', {
++ class: 'muted',
++ text: 'Earn gems from stages, quests, logins and the idle chest. Every item lists exactly what you get.',
++ }),
++ ),
++ ),
++ )
++ const grid = h('div', { class: 'shop-grid' })
++ for (const p of pack.store) {
++ const bought = boughtThisPeriod(s, p, now)
++ const left = p.limit.count - bought
++ const reward = productReward(pack, s, p.id, now) ?? {}
++ const per =
++ p.limit.period === 'daily' ? 'today' : p.limit.period === 'weekly' ? 'this week' : 'ever'
++ const buy = button(
++ [icon('gems'), String(p.gems)],
++ () => confirmBuy(app, p.id),
++ 'btn btn-gold btn-small',
++ {
++ 'aria-label': `Buy ${p.name} for ${p.gems} gems`,
++ },
++ )
++ buy.disabled = left <= 0 || s.wallet.gems + s.wallet.gemsPaid < p.gems
++ grid.append(
++ h(
++ 'article',
++ { class: `product${left <= 0 ? ' sold' : ''}` },
++ h('h3', { text: p.name }),
++ h('p', { class: 'muted', text: p.text }),
++ rewardChips(pack, reward),
++ h(
++ 'div',
++ { class: 'product-foot' },
++ h('small', {
++ class: 'muted',
++ text: left > 0 ? `${left} left ${per}` : `Sold out ${per}`,
++ }),
++ buy,
++ ),
++ ),
++ )
++ }
++ view.append(grid)
++ return view
++}
++
++function confirmBuy(app: App, id: string): void {
++ const p = app.pack.store.find((x) => x.id === id)
++ if (!p) return
++ const s = sheet(p.name)
++ const reward = productReward(app.pack, app.state, id, app.now()) ?? {}
++ s.body.append(
++ h('p', { text: 'You get exactly:' }),
++ rewardChips(app.pack, reward),
++ h(
++ 'p',
++ null,
++ 'Price: ',
++ h('b', { text: `${p.gems} gems` }),
++ app.state.wallet.gems < p.gems && app.state.wallet.gemsPaid
++ ? h('span', { class: 'muted', text: ' (earned gems first, then bought ones)' })
++ : null,
++ ),
++ button(
++ 'Buy',
++ async () => {
++ if (await app.run({ t: 'buy', product: id })) {
++ s.close()
++ toast(`Bought ${p.name}`, 'good')
++ }
++ },
++ 'btn btn-gold btn-wide',
++ ),
++ )
++}
ddiff --git a/apps/client/src/casual/views/summon.ts b/apps/client/src/casual/views/summon.ts
nnew file mode 100644
iindex 0000000..db7093e
---- /dev/null
++++ b/apps/client/src/casual/views/summon.ts
@@@ -0,0 +1,185 @@
++import { drawOdds, featuredFor, heroDef, type Rarity } from '@openvibe/casual'
++import { RARITY_NAME, heroPortrait, icon } from '../art.js'
++import { button, duration, h, sheet, tick } from '../dom.js'
++import type { App } from '../app.js'
++import { heroSheet } from './heroes.js'
++
++export function summonView(app: App): HTMLElement {
++ const { pack, state: s } = app
++ const b = pack.banner
++ const f = featuredFor(pack, app.now())
++ const legend = heroDef(pack, f.legendary)
++ const epic = heroDef(pack, f.epic)
++ const view = h('div', { class: 'summon' })
++ const left = b.pity - s.summon.pity
++ const countdown = h('span')
++ tick(countdown, () => `Changes in ${duration(f.until - app.now())}`)
++ const art = h('div', { class: 'banner-art' })
++ if (epic)
++ art.append(h('div', { class: `banner-hero second el-bg-${epic.element}` }, heroPortrait(epic)))
++ if (legend)
++ art.append(
++ h('div', { class: `banner-hero first el-bg-${legend.element}` }, heroPortrait(legend)),
++ )
++ view.append(
++ h(
++ 'article',
++ { class: 'banner' },
++ art,
++ h(
++ 'div',
++ { class: 'banner-text' },
++ h('small', { text: 'This week’s featured heroes' }),
++ h('h1', { text: b.name }),
++ h(
++ 'p',
++ null,
++ legend ? h('b', { text: `${legend.name}` }) : null,
++ ' and ',
++ epic ? h('b', { text: epic.name }) : null,
++ ' take half of their rarity’s chance.',
++ ),
++ h('p', { class: 'muted' }, countdown),
++ ),
++ ),
++ h(
++ 'div',
++ { class: 'pity', attrs: { role: 'group', 'aria-label': 'Legendary guarantee' } },
++ h(
++ 'div',
++ { class: 'pity-bar' },
++ h('i', { style: { width: `${(s.summon.pity / b.pity) * 100}%` } }),
++ ),
++ h('span', {
++ text:
++ left <= 1
++ ? 'The next draw is a legendary'
++ : `A legendary within ${left} draws, guaranteed`,
++ }),
++ ),
++ )
++ const pull = (count: 1 | 10) => {
++ const btn = button(
++ [
++ h('span', { class: 'big', text: `Summon ×${count}` }),
++ h('small', null, icon('sigils'), String(b.cost * count)),
++ ],
++ async () => {
++ const a = await app.run({ t: 'summon', count }, { quiet: true })
++ if (a) reveal(app, a.result.pulls as Pull[])
++ },
++ `btn ${count === 10 ? 'btn-gold' : 'btn-violet'} btn-summon`,
++ )
++ btn.disabled = s.wallet.sigils < b.cost * count
++ return btn
++ }
++ view.append(
++ h('div', { class: 'summon-btns' }, pull(1), pull(10)),
++ h(
++ 'p',
++ { class: 'muted center' },
++ `You have ${s.wallet.sigils} sigils. Sigils come only from playing: quests, login rewards and stages. They are never sold. Ten at once always holds an epic or better.`,
++ ),
++ button('See the odds of every hero', () => oddsSheet(app), 'btn btn-link'),
++ )
++ return view
++}
++
++interface Pull {
++ hero: string
++ rarity: Rarity
++ new: boolean
++ shards: number
++}
++
++function reveal(app: App, pulls: Pull[]): void {
++ const s = sheet(pulls.length === 1 ? 'Summon' : 'Summon ×10', {
++ wide: true,
++ onClose: () => app.render(),
++ })
++ const grid = h('div', { class: `reveal${pulls.length === 1 ? ' one' : ''}` })
++ pulls.forEach((p, i) => {
++ const def = heroDef(app.pack, p.hero)
++ if (!def) return
++ const card = h(
++ 'div',
++ { class: `reveal-card r-${p.rarity}`, style: { 'animation-delay': `${i * 110}ms` } },
++ h('div', { class: `hero-art el-bg-${def.element}` }, heroPortrait(def, true)),
++ h('b', { text: def.name.split(' ')[0] ?? def.name }),
++ h('small', {
++ text: p.new ? `New ${RARITY_NAME[p.rarity].toLowerCase()}!` : `+${p.shards} shards`,
++ }),
++ )
++ grid.append(
++ button(card, () => heroSheet(app, def.id), 'pick', {
++ 'aria-label': `${def.name}, ${p.new ? 'new' : `${p.shards} shards`}`,
++ }),
++ )
++ })
++ s.body.append(
++ grid,
++ button('Done', () => s.close(), 'btn btn-gold btn-wide'),
++ )
++}
++
++function oddsSheet(app: App): void {
++ const s = sheet('Summon odds', { wide: true })
++ const b = app.pack.banner
++ const odds = drawOdds(app.pack, app.now())
++ const pct = (x: number) => `${(x * 100).toFixed(x < 0.01 ? 3 : 2)}%`
++ s.body.append(
++ h(
++ 'p',
++ null,
++ 'Each draw costs ',
++ h('b', { text: `${b.cost} sigil` }),
++ '. Sigils are earned in play only and are never sold, so no draw is ever bought with money.',
++ ),
++ h(
++ 'ul',
++ { class: 'odds-rules' },
++ h('li', {
++ text: `Legendary ${pct(b.odds.legendary)}, epic ${pct(b.odds.epic)}, rare ${pct(b.odds.rare)}, common ${pct(b.odds.common)}.`,
++ }),
++ h('li', {
++ text: `The featured legendary and epic each take half of their rarity’s chance; the rest is shared evenly.`,
++ }),
++ h('li', {
++ text: `A legendary is certain by draw ${b.pity} since the last one (your counter is shown above the buttons).`,
++ }),
++ h('li', { text: 'Ten at once always holds at least one epic or legendary.' }),
++ h('li', {
++ text: `A hero you already have turns into shards: ${b.duplicateShards.common} common, ${b.duplicateShards.rare} rare, ${b.duplicateShards.epic} epic, ${b.duplicateShards.legendary} legendary.`,
++ }),
++ ),
++ )
++ const table = h('table', { class: 'odds' })
++ table.append(
++ h(
++ 'thead',
++ null,
++ h(
++ 'tr',
++ null,
++ h('th', { text: 'Hero' }),
++ h('th', { text: 'Rarity' }),
++ h('th', { text: 'Chance' }),
++ ),
++ ),
++ )
++ const body = h('tbody')
++ for (const o of odds) {
++ const def = heroDef(app.pack, o.hero)
++ body.append(
++ h(
++ 'tr',
++ null,
++ h('td', { text: def?.name ?? o.hero }),
++ h('td', { text: RARITY_NAME[o.rarity] }),
++ h('td', { text: pct(o.chance) }),
++ ),
++ )
++ }
++ table.append(body)
++ s.body.append(table)
++}
ddiff --git a/apps/client/src/net/connection.ts b/apps/client/src/net/connection.ts
iindex 8f09a4f..3170499 100644
---- a/apps/client/src/net/connection.ts
++++ b/apps/client/src/net/connection.ts
@@@ -162,28 +162,7 @@ export class Connection {
}
}
--/** Persistent anonymous identity (interim auth — see ADR-0004). */
--/**
-- * Guest identity token: localStorage primary, a long-lived cookie as backup
-- * (survives localStorage wipes).
-- */
--export function getIdentity(): { token: string; name: string | null } {
-- const cookieTok = document.cookie
-- .split('; ')
-- .find((c) => c.startsWith('openvibe.token='))
-- ?.slice('openvibe.token='.length)
-- let token = localStorage.getItem('openvibe.token') ?? cookieTok ?? null
-- if (!token || !/^[a-z0-9]{8,64}$/i.test(token)) {
-- token = crypto.randomUUID().replaceAll('-', '').slice(0, 32)
-- }
-- localStorage.setItem('openvibe.token', token)
-- document.cookie = `openvibe.token=${token}; Path=/; Max-Age=${400 * 86400}; SameSite=Lax`
-- return { token, name: localStorage.getItem('openvibe.name') }
--}
--
--export function saveName(name: string): void {
-- localStorage.setItem('openvibe.name', name)
--}
++export { getIdentity, saveName } from './identity.js'
export function gameSocketUrl(): string {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
ddiff --git a/apps/client/src/net/identity.ts b/apps/client/src/net/identity.ts
nnew file mode 100644
iindex 0000000..3424fc2
---- /dev/null
++++ b/apps/client/src/net/identity.ts
@@@ -0,0 +1,62 @@
++/**
++ * The browser's Games identity, shared by every game on the site: a guest token (letters and digits) kept in
++ * localStorage with a long-lived cookie as backup, and the Network session the sign-in left in `ovg_sso`.
++ */
++/**
++ * Guest identity token: localStorage primary, a long-lived cookie as backup
++ * (survives localStorage wipes, and works where an in-app browser refuses storage).
++ */
++export function getIdentity(): { token: string; name: string | null } {
++ const cookieTok = document.cookie
++ .split('; ')
++ .find((c) => c.startsWith('openvibe.token='))
++ ?.slice('openvibe.token='.length)
++ let stored: string | null = null
++ let name: string | null = null
++ try {
++ stored = localStorage.getItem('openvibe.token')
++ name = localStorage.getItem('openvibe.name')
++ } catch {
++ stored = null
++ }
++ let token = stored ?? cookieTok ?? null
++ if (!token || !/^[a-z0-9]{8,64}$/i.test(token)) {
++ token = crypto.randomUUID().replaceAll('-', '').slice(0, 32)
++ }
++ try {
++ localStorage.setItem('openvibe.token', token)
++ } catch {
++ /* the cookie below still keeps it */
++ }
++ document.cookie = `openvibe.token=${token}; Path=/; Max-Age=${400 * 86400}; SameSite=Lax`
++ return { token, name }
++}
++
++export function saveName(name: string): void {
++ localStorage.setItem('openvibe.name', name)
++}
++
++/** The openvibe.network access token this browser holds for the site (set by /auth/callback), if any. */
++export function sessionToken(): string | undefined {
++ let stored: string | null = null
++ try {
++ stored = localStorage.getItem('ovg_sso')
++ } catch {
++ stored = null
++ }
++ const cookie = document.cookie
++ .split('; ')
++ .find((c) => c.startsWith('ovg_sso='))
++ ?.slice('ovg_sso='.length)
++ return stored ?? (cookie ? decodeURIComponent(cookie) : undefined)
++}
++
++/** Forgets a session the server called expired, so the page carries on as the guest. */
++export function forgetSession(): void {
++ try {
++ localStorage.removeItem('ovg_sso')
++ } catch {
++ /* storage unavailable */
++ }
++ document.cookie = 'ovg_sso=; Path=/; Max-Age=0; SameSite=Lax'
++}
ddiff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json
iindex 06f65d9..f7cb6f3 100644
---- a/apps/client/tsconfig.json
++++ b/apps/client/tsconfig.json
@@@ -16,6 +16,7 @@
"include": ["src"],
"references": [
{ "path": "../../packages/shared" },
++ { "path": "../../packages/casual" },
{ "path": "../../packages/protocol" },
{ "path": "../../packages/content" },
{ "path": "../../packages/gameplay" },
ddiff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts
iindex ca72bdd..8a5d495 100644
---- a/apps/client/vite.config.ts
++++ b/apps/client/vite.config.ts
@@@ -10,6 +10,7 @@ export default defineConfig({
server.middlewares.use((req, _res, next) => {
if (req.url === '/play') req.url = '/play.html'
else if (req.url === '/editor') req.url = '/editor.html'
++ else if (/^\/play\/[a-z0-9-]+(\?|$)/.test(req.url ?? '')) req.url = '/casual.html'
next()
})
},
@@@ -38,6 +39,7 @@ export default defineConfig({
main: 'play.html',
editor: 'editor.html',
leaderboard: 'leaderboard.html',
++ casual: 'casual.html',
},
},
outDir: 'dist',
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 134d87b..9ee7a74 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -12,6 +12,7 @@
"dependencies": {
"@dimforge/rapier3d-deterministic-compat": "0.21.0",
"@electric-sql/pglite": "^0.5.8",
++ "@openvibe/casual": "workspace:*",
"@openvibe/content": "workspace:*",
"@openvibe/gameplay": "workspace:*",
"@openvibe/persistence": "workspace:*",
ddiff --git a/apps/server/src/casual/casual.test.ts b/apps/server/src/casual/casual.test.ts
nnew file mode 100644
iindex 0000000..a3f39fb
---- /dev/null
++++ b/apps/server/src/casual/casual.test.ts
@@@ -0,0 +1,248 @@
++import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
++import type { Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
++import { sql, type Db } from 'openvibe-sdk/db'
++import { openTestDb } from '@openvibe/persistence/testing'
++import { createConsoleLogger } from '@openvibe/shared'
++import { packDigest, vibeHeroes } from '@openvibe/casual'
++import { ServerMetrics } from '../observability/metrics.js'
++import { createHttpServer } from '../net/httpServer.js'
++import { createGamesActorLimits } from '../net/actorLimits.js'
++import type { NetworkUser } from '../net/networkAuth.js'
++import { createCasualRoutes } from './routes.js'
++import { casualGameOf, casualHead } from './page.js'
++
++const log = createConsoleLogger({ app: 'test' }, 'error')
++// Answers are read freely in assertions; their shapes are what the tests check.
++// eslint-disable-next-line @typescript-eslint/no-explicit-any
++type Body = Record<string, any>
++const GUEST = 'g0000000000000000000000000000001'
++const ANA = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1'
++const DIGEST = packDigest(vibeHeroes)
++let db: Db
++let server: Server
++let base = ''
++let dir = ''
++let clock = Date.UTC(2026, 9, 14, 12)
++const users = new Map<string, NetworkUser>([
++ ['ana-token', { id: '1', name: 'ana', rank: null, subjectId: ANA }],
++])
++
++beforeAll(async () => {
++ dir = mkdtempSync(join(tmpdir(), 'casual-'))
++ const dist = join(dir, 'dist')
++ mkdirSync(dist)
++ writeFileSync(
++ join(dist, 'casual.html'),
++ '<!doctype html><html><head><!--casual:head--></head><body><main id="app"></main></body></html>',
++ )
++ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal</title>')
++ db = await openTestDb()
++ const routes = createCasualRoutes({
++ db,
++ networkAuthUrl: 'http://network.test/api/auth/me',
++ limits: createGamesActorLimits({ env: {} }),
++ address: () => '127.0.0.1',
++ log,
++ now: () => clock,
++ resolveUser: async (_url, token) => users.get(token) ?? null,
++ })
++ server = createHttpServer(
++ dist,
++ new ServerMetrics(),
++ log,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ null,
++ {
++ handle: routes,
++ },
++ )
++ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
++ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
++})
++
++afterAll(() => {
++ server.close()
++ rmSync(dir, { recursive: true, force: true })
++})
++
++beforeEach(async () => {
++ await db.query('TRUNCATE casual_saves, casual_ledger, casual_actions')
++ clock = Date.UTC(2026, 9, 14, 12)
++})
++
++async function call(
++ path: string,
++ opts: { body?: unknown; guest?: string; bearer?: string; digest?: string; method?: string } = {},
++): Promise<{ status: number; body: Body }> {
++ const headers: Record<string, string> = {}
++ if (opts.body !== undefined) headers['content-type'] = 'application/json'
++ if (opts.guest) headers['x-guest-token'] = opts.guest
++ if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`
++ if (opts.digest) headers['x-casual-digest'] = opts.digest
++ const r = await fetch(`${base}${path}`, {
++ method: opts.method ?? (opts.body === undefined ? 'GET' : 'POST'),
++ headers,
++ ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),
++ redirect: 'manual',
++ })
++ const text = await r.text()
++ let body: Body = {}
++ try {
++ body = JSON.parse(text) as Body
++ } catch {
++ body = { text, location: r.headers.get('location') }
++ }
++ return { status: r.status, body }
++}
++
++const S = '/api/v1/casual/vibe-heroes'
++const act = (
++ action: unknown,
++ key: string,
++ who: { guest?: string; bearer?: string } = { guest: GUEST },
++) => call(`${S}/actions`, { body: { key, action }, digest: DIGEST, ...who })
++
++describe('casual sessions', () => {
++ it('creates a guest’s save on first visit with the ad link’s source, once', async () => {
++ const first = await call(`${S}/session`, { body: { src: 'x', c: 'launch-1' }, guest: GUEST })
++ expect(first.status).toBe(200)
++ expect(first.body.created).toBe(true)
++ expect(first.body.digest).toBe(DIGEST)
++ expect(first.body.player).toEqual({ signedIn: false, name: null })
++ expect(first.body.state.team).toEqual(['gus', 'tess', 'milo', 'rook'])
++ const again = await call(`${S}/session`, { body: { src: 'other' }, guest: GUEST })
++ expect(again.body.created).toBe(false)
++ expect(await db.many(sql`SELECT source, campaign, player_name FROM casual_saves`)).toEqual([
++ { source: 'x', campaign: 'launch-1', player_name: null },
++ ])
++ expect((await db.many(sql`SELECT asset FROM casual_ledger ORDER BY id`)).length).toBe(7)
++ })
++
++ it('refuses a request with no identity, a bad body or the wrong method', async () => {
++ expect((await call(`${S}/session`, { body: {} })).status).toBe(401)
++ expect((await call(`${S}/session`, { method: 'GET' })).status).toBe(405)
++ expect((await call('/api/v1/casual/nope/session', { body: {}, guest: GUEST })).status).toBe(404)
++ const r = await fetch(`${base}${S}/session`, {
++ method: 'POST',
++ headers: { 'x-guest-token': GUEST, 'content-type': 'text/plain' },
++ body: '{}',
++ })
++ expect(r.status).toBe(415)
++ })
++
++ it('answers an expired sign-in as such instead of falling back to the guest', async () => {
++ const r = await call(`${S}/session`, { body: {}, bearer: 'old-token', guest: GUEST })
++ expect(r.status).toBe(401)
++ expect(r.body.error).toBe('session_expired')
++ })
++})
++
++describe('casual actions', () => {
++ it('applies a battle once per key, with ledger lines, and replays the same answer', async () => {
++ await call(`${S}/session`, { body: {}, guest: GUEST })
++ const one = await act({ t: 'battle', stage: 1 }, 'key-0000001')
++ expect(one.status).toBe(200)
++ expect(one.body.result.won).toBe(true)
++ expect(one.body.state.stage.cleared).toBe(1)
++ expect(one.body.version).toBe(1)
++ const gold = one.body.state.wallet.gold
++ const replay = await act({ t: 'battle', stage: 1 }, 'key-0000001')
++ expect(replay.body.replay).toBe(true)
++ expect(replay.body.state.wallet.gold).toBe(gold)
++ expect(replay.body.result.battle).toEqual(one.body.result.battle)
++ const lines = await db.many<{ action_key: string; asset: string; delta: number }>(
++ sql`SELECT action_key, asset, delta FROM casual_ledger WHERE action_key = 'key-0000001' ORDER BY id`,
++ )
++ expect(lines).toEqual([
++ { action_key: 'key-0000001', asset: 'gold', delta: 350 },
++ { action_key: 'key-0000001', asset: 'gems', delta: 15 },
++ { action_key: 'key-0000001', asset: 'shard:ivy', delta: 5 },
++ ])
++ })
++
++ it('answers a refusal without changing anything', async () => {
++ await call(`${S}/session`, { body: {}, guest: GUEST })
++ const r = await act({ t: 'battle', stage: 5 }, 'key-0000002')
++ expect(r.status).toBe(422)
++ expect(r.body.error).toBe('locked_stage')
++ const row = await db.one<{ version: number }>(sql`SELECT version FROM casual_saves`)
++ expect(row.version).toBe(0)
++ })
++
++ it('asks a stale page to reload, and refuses malformed actions', async () => {
++ await call(`${S}/session`, { body: {}, guest: GUEST })
++ const stale = await call(`${S}/actions`, {
++ body: { key: 'key-0000003', action: { t: 'collect' } },
++ digest: 'deadbeef',
++ guest: GUEST,
++ })
++ expect(stale.status).toBe(409)
++ expect(stale.body.error).toBe('content_changed')
++ expect((await act({ t: 'grant', gold: 1e9 }, 'key-0000004')).status).toBe(400)
++ expect((await act({ t: 'collect' }, 'short')).status).toBe(400)
++ })
++
++ it('needs a save first', async () => {
++ const r = await act({ t: 'login' }, 'key-0000005')
++ expect(r.status).toBe(404)
++ })
++})
++
++describe('signing in', () => {
++ it('takes over the guest’s save on that browser, names it, and lists it on the board', async () => {
++ await call(`${S}/session`, { body: {}, guest: GUEST })
++ await act({ t: 'battle', stage: 1 }, 'key-0000010')
++ const r = await call(`${S}/session`, { body: {}, bearer: 'ana-token', guest: GUEST })
++ expect(r.status).toBe(200)
++ expect(r.body.created).toBe(false)
++ expect(r.body.player).toEqual({ signedIn: true, name: 'ana' })
++ expect(r.body.state.stage.cleared).toBe(1)
++ expect(await db.many(sql`SELECT account_key, player_name FROM casual_saves`)).toEqual([
++ { account_key: ANA, player_name: 'ana' },
++ ])
++ const board = await call(`${S}/leaderboard`)
++ expect(board.body.entries).toEqual([
++ { rank: 1, name: 'ana', stage: 1, power: expect.any(Number) },
++ ])
++ const ledger = await call(`${S}/ledger`, { bearer: 'ana-token' })
++ expect(ledger.status).toBe(200)
++ expect(ledger.body.lines[0].asset).toBe('shard:ivy')
++ expect(ledger.body.next).toBeNull()
++ })
++
++ it('keeps guests off the board', async () => {
++ await call(`${S}/session`, { body: {}, guest: GUEST })
++ await act({ t: 'battle', stage: 1 }, 'key-0000020')
++ expect((await call(`${S}/leaderboard`)).body.entries).toEqual([])
++ })
++})
++
++describe('the casual page', () => {
++ it('serves /play/<game> with the game’s share card and keeps the campaign through redirects', async () => {
++ const page = await fetch(`${base}/play/vibe-heroes?src=x&c=t1`)
++ expect(page.status).toBe(200)
++ const html = await page.text()
++ expect(html).toContain('<body data-game="vibe-heroes">')
++ expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />')
++ expect(html).toContain('https://openvibe.games/casual/vibe-heroes/card.png')
++ expect(html).toContain('"@type":"WebApplication"')
++ const slash = await call('/play/vibe-heroes/?src=x&c=t1')
++ expect(slash.status).toBe(301)
++ expect(slash.body.location).toBe('/play/vibe-heroes?src=x&c=t1')
++ expect((await fetch(`${base}/play/not-a-game`)).status).toBe(404)
++ expect((await fetch(`${base}/casual.html`)).status).toBe(404)
++ })
++
++ it('names only real games and escapes what it writes', () => {
++ expect(casualGameOf('/play/vibe-heroes')).toBe('vibe-heroes')
++ expect(casualGameOf('/play/../etc')).toBeNull()
++ expect(casualHead('vibe-heroes')).toContain('<title>Vibe Heroes · OpenVibe.Games</title>')
++ })
++})
ddiff --git a/apps/server/src/casual/page.ts b/apps/server/src/casual/page.ts
nnew file mode 100644
iindex 0000000..1e112f8
---- /dev/null
++++ b/apps/server/src/casual/page.ts
@@@ -0,0 +1,57 @@
++/**
++ * A casual game's page (plan T22): the one built `casual.html` with the game's own title, description, share card
++ * (Open Graph and X) and JSON-LD filled in where the build left `<!--casual:head-->`. A shared or advertised link
++ * unfurls as the game, and the page knows which game to start from `<body data-game>`.
++ */
++import { readFileSync, statSync } from 'node:fs'
++import { CASUAL_GAMES } from '@openvibe/casual'
++import { casualJsonLdTag } from '../net/discovery.js'
++
++const SITE = 'https://openvibe.games'
++const MARK = '<!--casual:head-->'
++
++const esc = (s: string): string =>
++ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
++
++/** The id of the casual game a `/play/<id>` path names, or null. */
++export function casualGameOf(path: string): string | null {
++ const id = /^\/play\/([a-z0-9-]{1,40})\/?$/.exec(path)?.[1]
++ return id && CASUAL_GAMES[id] ? id : null
++}
++
++export function casualHead(id: string): string {
++ const pack = CASUAL_GAMES[id]
++ if (!pack) return ''
++ const url = `${SITE}/play/${pack.id}`
++ const title = `${pack.name} · OpenVibe.Games`
++ const image = `${SITE}/casual/${pack.id}/card.png`
++ return [
++ `<title>${esc(title)}</title>`,
++ `<meta name="description" content="${esc(pack.tagline)}" />`,
++ `<link rel="canonical" href="${url}" />`,
++ `<meta property="og:type" content="website" />`,
++ `<meta property="og:site_name" content="OpenVibe.Games" />`,
++ `<meta property="og:title" content="${esc(pack.name)}" />`,
++ `<meta property="og:description" content="${esc(pack.tagline)}" />`,
++ `<meta property="og:url" content="${url}" />`,
++ `<meta property="og:image" content="${image}" />`,
++ `<meta property="og:image:width" content="1200" />`,
++ `<meta property="og:image:height" content="630" />`,
++ `<meta name="twitter:card" content="summary_large_image" />`,
++ `<meta name="twitter:title" content="${esc(pack.name)}" />`,
++ `<meta name="twitter:description" content="${esc(pack.tagline)}" />`,
++ `<meta name="twitter:image" content="${image}" />`,
++ casualJsonLdTag(pack.id),
++ ].join('\n ')
++}
++
++let memo: { path: string; mtimeMs: number; raw: string } | null = null
++
++/** The built page for one game (the file is read once per build). */
++export function casualPageHtml(filePath: string, id: string): string {
++ const mtimeMs = statSync(filePath).mtimeMs
++ if (!memo || memo.path !== filePath || memo.mtimeMs !== mtimeMs) {
++ memo = { path: filePath, mtimeMs, raw: readFileSync(filePath, 'utf8') }
++ }
++ return memo.raw.replace(MARK, casualHead(id)).replace('<body>', `<body data-game="${esc(id)}">`)
++}
ddiff --git a/apps/server/src/casual/routes.ts b/apps/server/src/casual/routes.ts
nnew file mode 100644
iindex 0000000..a6bf9cd
---- /dev/null
++++ b/apps/server/src/casual/routes.ts
@@@ -0,0 +1,224 @@
++/**
++ * The casual games' HTTP API (plan T22). The page talks to it with small JSON requests: no WebSocket, so a
++ * phone in an in-app browser starts in one round trip.
++ *
++ * POST /api/v1/casual/:game/session the player's save (created on first visit; `src`/`c` kept then)
++ * POST /api/v1/casual/:game/actions { key, action } → the new state, under the idempotency key
++ * GET /api/v1/casual/:game/leaderboard the furthest signed-in players
++ * GET /api/v1/casual/:game/ledger the player's own currency and item history
++ *
++ * Identity: a Network session (Authorization: Bearer) or the browser's Games guest token (X-Guest-Token), the
++ * same two the 3D games use. A signed-in request that still carries the guest token takes over that guest's save.
++ */
++import { createHash } from 'node:crypto'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import type { Db } from 'openvibe-sdk/db'
++import type { Logger } from '@openvibe/shared'
++import { CASUAL_GAMES, packDigest, parseAction, type CasualPack } from '@openvibe/casual'
++import { isGuestToken } from '../platform/accounts.js'
++import { hashGuestKey } from '../net/guestIdentity.js'
++import { resolveNetworkUser, type NetworkUser } from '../net/networkAuth.js'
++import type { GamesActorLimits } from '../net/actorLimits.js'
++import { applyAction, casualLeaderboard, casualLedger, openSave } from './store.js'
++
++export interface CasualRouteOptions {
++ db: Db
++ networkAuthUrl: string | null
++ limits: GamesActorLimits
++ address: (req: IncomingMessage) => string
++ log: Logger
++ now?: () => number
++ /** Test seam for the Network session check. */
++ resolveUser?: (url: string | null, token: string) => Promise<NetworkUser | null>
++}
++
++interface Who {
++ key: string
++ signedIn: boolean
++ name: string | null
++ guestKey: string | null
++}
++
++const PATH = /^\/api\/v1\/casual\/([a-z0-9-]{1,40})\/(session|actions|leaderboard|ledger)$/
++const ACTION_KEY = /^[A-Za-z0-9_-]{8,64}$/
++const TAG = /^[A-Za-z0-9_-]{1,32}$/
++const MAX_BODY = 4096
++const SESSION_TTL_MS = 60_000
++const MAX_SESSIONS = 10_000
++
++function readJson(req: IncomingMessage): Promise<unknown> {
++ return new Promise((resolve) => {
++ const chunks: Buffer[] = []
++ let size = 0
++ let settled = false
++ const finish = (v: unknown) => {
++ if (!settled) {
++ settled = true
++ resolve(v)
++ }
++ }
++ req.on('data', (c: Buffer) => {
++ size += c.length
++ if (size > MAX_BODY) {
++ finish(undefined)
++ req.destroy()
++ } else chunks.push(c)
++ })
++ req.on('end', () => {
++ try {
++ finish(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
++ } catch {
++ finish(undefined)
++ }
++ })
++ req.on('error', () => finish(undefined))
++ req.on('close', () => finish(undefined))
++ })
++}
++
++export function createCasualRoutes(
++ opts: CasualRouteOptions,
++): (req: IncomingMessage, res: ServerResponse) => boolean {
++ const now = opts.now ?? Date.now
++ const resolveUser = opts.resolveUser ?? resolveNetworkUser
++ const digests = new Map(Object.values(CASUAL_GAMES).map((p) => [p.id, packDigest(p)]))
++ // A Network session is checked once a minute per token (by its hash), not on every tap.
++ const sessions = new Map<string, { user: NetworkUser | null; at: number }>()
++
++ async function who(req: IncomingMessage): Promise<Who | 'expired' | null> {
++ const guest = req.headers['x-guest-token']
++ const guestKey =
++ typeof guest === 'string' && isGuestToken(guest) ? `guest:${hashGuestKey(guest)}` : null
++ const bearer = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? '')?.[1]
++ if (bearer) {
++ const h = createHash('sha256').update(bearer).digest('hex')
++ let hit = sessions.get(h)
++ if (!hit || now() - hit.at > SESSION_TTL_MS) {
++ hit = { user: await resolveUser(opts.networkAuthUrl, bearer), at: now() }
++ if (sessions.size >= MAX_SESSIONS) sessions.clear()
++ sessions.set(h, hit)
++ }
++ const user = hit.user
++ if (!user?.subjectId) return 'expired'
++ return { key: user.subjectId, signedIn: true, name: user.name, guestKey }
++ }
++ if (guestKey) return { key: guestKey, signedIn: false, name: null, guestKey: null }
++ return null
++ }
++
++ return (req, res) => {
++ const url = new URL(req.url ?? '/', 'http://games.local')
++ const m = PATH.exec(url.pathname)
++ if (!m) return false
++ const send = (status: number, body: unknown, headers: Record<string, string> = {}) => {
++ if (res.headersSent) return
++ res.writeHead(status, {
++ 'content-type': 'application/json',
++ 'cache-control': 'private, no-store',
++ ...headers,
++ })
++ res.end(JSON.stringify(body))
++ }
++ const pack: CasualPack | undefined = CASUAL_GAMES[m[1] ?? '']
++ const route = m[2]
++ const method = route === 'leaderboard' || route === 'ledger' ? 'GET' : 'POST'
++ if (req.method !== method) {
++ send(405, { error: 'method_not_allowed' }, { allow: method })
++ return true
++ }
++ if (!pack) {
++ send(404, { error: 'unknown_game' })
++ return true
++ }
++ void (async () => {
++ if (route === 'leaderboard') {
++ if (!(await opts.limits.publicRead(req, res, `ip:${opts.address(req)}`))) return
++ send(200, { entries: await casualLeaderboard(opts.db, pack.id) })
++ return
++ }
++ if (method === 'POST') {
++ const type = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
++ if (type !== 'application/json') {
++ send(415, { error: 'invalid_request', message: 'Send application/json.' })
++ return
++ }
++ }
++ const id = await who(req)
++ if (id === 'expired') {
++ send(401, {
++ error: 'session_expired',
++ message: 'Your OpenVibe sign-in expired. Sign in again.',
++ })
++ return
++ }
++ if (!id) {
++ send(401, { error: 'unauthorized', message: 'Missing a guest token or a sign-in.' })
++ return
++ }
++ if (route === 'ledger') {
++ if (!(await opts.limits.publicRead(req, res, `casual:${id.key}`))) return
++ const before = Number(url.searchParams.get('before') ?? '')
++ send(
++ 200,
++ await casualLedger(
++ opts.db,
++ pack.id,
++ id.key,
++ Number.isSafeInteger(before) && before > 0 ? before : null,
++ ),
++ )
++ return
++ }
++ if (route === 'session') {
++ if (!(await opts.limits.casualSession(req, res, `ip:${opts.address(req)}`))) return
++ const body = (await readJson(req)) as { src?: unknown; c?: unknown } | undefined
++ if (!body || typeof body !== 'object') {
++ send(400, { error: 'invalid_request', message: 'Expected a JSON object.' })
++ return
++ }
++ const tag = (v: unknown) => (typeof v === 'string' && TAG.test(v) ? v.toLowerCase() : null)
++ const t = now()
++ const save = await openSave(opts.db, pack, id.key, {
++ now: t,
++ name: id.name,
++ guestKey: id.guestKey,
++ source: tag(body.src),
++ campaign: tag(body.c),
++ })
++ send(200, {
++ state: save.state,
++ version: save.version,
++ created: save.created,
++ now: t,
++ digest: digests.get(pack.id),
++ player: { signedIn: id.signedIn, name: id.name },
++ })
++ return
++ }
++ if (!(await opts.limits.casualAction(req, res, `casual:${id.key}`))) return
++ if (req.headers['x-casual-digest'] !== digests.get(pack.id)) {
++ send(409, {
++ error: 'content_changed',
++ message: 'The game was updated. Reload to play the new version.',
++ })
++ return
++ }
++ const body = (await readJson(req)) as { key?: unknown; action?: unknown } | undefined
++ const action = parseAction(body?.action)
++ if (!body || typeof body.key !== 'string' || !ACTION_KEY.test(body.key) || !action) {
++ send(400, { error: 'invalid_request', message: 'Expected { key, action }.' })
++ return
++ }
++ const t = now()
++ const answer = await applyAction(opts.db, pack, id.key, body.key, action, t)
++ send(answer.status, answer.status === 200 ? { ...answer.body, now: t } : answer.body)
++ })().catch((err: unknown) => {
++ opts.log.warn('casual request failed', {
++ route: route ?? '',
++ error: err instanceof Error ? err.message : String(err),
++ })
++ send(503, { error: 'unavailable', message: 'The game server is busy. Try again.' })
++ })
++ return true
++ }
++}
ddiff --git a/apps/server/src/casual/store.ts b/apps/server/src/casual/store.ts
nnew file mode 100644
iindex 0000000..7cb076e
---- /dev/null
++++ b/apps/server/src/casual/store.ts
@@@ -0,0 +1,202 @@
++/**
++ * The casual kit's server economy (plan T22). A player's save is one row; every action runs in one transaction
++ * that locks the save, applies the kit's reducer, writes the new state and one ledger line per currency or item
++ * change, and records the action under the client's idempotency key. The same key again answers the stored result
++ * and changes nothing, so a retried request can never pay twice.
++ */
++import { randomInt } from 'node:crypto'
++import { sql, type Db } from 'openvibe-sdk/db'
++import { createRng, type Rng } from '@openvibe/shared'
++import { moveCasualSaves } from '@openvibe/persistence'
++import {
++ apply,
++ newPlayer,
++ Refusal,
++ teamPower,
++ type Action,
++ type CasualPack,
++ type Delta,
++ type PlayerState,
++} from '@openvibe/casual'
++
++export interface SaveView {
++ state: PlayerState
++ version: number
++ created: boolean
++}
++
++export type ActionAnswer =
++ | {
++ status: 200
++ body: { state: PlayerState; version: number; result: Record<string, unknown>; replay?: true }
++ }
++ | { status: 404; body: { error: 'no_save' } }
++ | { status: 422; body: { error: string; message: string } }
++
++const IDENTITY = /^(usr|gst)_/
++
++async function writeLedger(
++ db: Db,
++ game: string,
++ key: string,
++ actionKey: string,
++ deltas: Delta[],
++ now: number,
++): Promise<void> {
++ for (let i = 0; i < deltas.length; i += 200) {
++ const rows = deltas
++ .slice(i, i + 200)
++ .map(
++ (d) =>
++ sql`(${game}, ${key}, ${actionKey}, ${d.asset}, ${d.delta}, ${d.balance}, ${d.reason}, ${now})`,
++ )
++ await db.exec(sql`
++ INSERT INTO casual_ledger (game_id, account_key, action_key, asset, delta, balance, reason, at)
++ VALUES ${sql.join(rows, sql`, `)}
++ `)
++ }
++}
++
++/**
++ * The player's save, created on first visit. A signed-in player who still carries a guest token on this browser
++ * first takes over that guest's save when they have none of their own. `source` and `campaign` (an ad link's
++ * src and c) are kept only on the first visit.
++ */
++export async function openSave(
++ db: Db,
++ pack: CasualPack,
++ key: string,
++ opts: {
++ now: number
++ name?: string | null
++ guestKey?: string | null
++ source?: string | null
++ campaign?: string | null
++ },
++): Promise<SaveView> {
++ return db.tx(async () => {
++ if (opts.guestKey && IDENTITY.test(key)) await moveCasualSaves(db, opts.guestKey, key)
++ const row = await db.maybe<{ state: PlayerState; version: number; player_name: string | null }>(
++ sql`SELECT state, version, player_name FROM casual_saves WHERE game_id = ${pack.id} AND account_key = ${key} FOR UPDATE`,
++ )
++ // Only signed-in accounts are named (and so listed on the board); guests stay anonymous.
++ const name = key.startsWith('usr_') ? (opts.name ?? null) : null
++ if (row) {
++ if (name && name !== row.player_name) {
++ await db.exec(
++ sql`UPDATE casual_saves SET player_name = ${name} WHERE game_id = ${pack.id} AND account_key = ${key}`,
++ )
++ }
++ return { state: row.state, version: row.version, created: false }
++ }
++ const start = newPlayer(pack, opts.now)
++ await db.exec(sql`
++ INSERT INTO casual_saves (game_id, account_key, state, version, player_name, stage, power, source, campaign, created_at, updated_at)
++ VALUES (${pack.id}, ${key}, ${sql.json(start.state)}, 0, ${name}, 0, ${teamPower(pack, start.state)},
++ ${opts.source ?? null}, ${opts.campaign ?? null}, ${opts.now}, ${opts.now})
++ `)
++ await writeLedger(db, pack.id, key, 'start', start.deltas, opts.now)
++ return { state: start.state, version: 0, created: true }
++ })
++}
++
++/** Applies one action under its idempotency key. */
++export async function applyAction(
++ db: Db,
++ pack: CasualPack,
++ key: string,
++ actionKey: string,
++ action: Action,
++ now: number,
++ rng: Rng = createRng(randomInt(2 ** 32)),
++): Promise<ActionAnswer> {
++ return db.tx(async (): Promise<ActionAnswer> => {
++ const row = await db.maybe<{ state: PlayerState; version: number }>(
++ sql`SELECT state, version FROM casual_saves WHERE game_id = ${pack.id} AND account_key = ${key} FOR UPDATE`,
++ )
++ if (!row) return { status: 404, body: { error: 'no_save' } }
++ const done = await db.maybe<{ response: { result: Record<string, unknown> } }>(
++ sql`SELECT response FROM casual_actions WHERE game_id = ${pack.id} AND account_key = ${key} AND action_key = ${actionKey}`,
++ )
++ if (done) {
++ return {
++ status: 200,
++ body: {
++ state: row.state,
++ version: row.version,
++ result: done.response.result,
++ replay: true,
++ },
++ }
++ }
++ let outcome
++ try {
++ outcome = apply(pack, row.state, action, now, rng)
++ } catch (err) {
++ if (err instanceof Refusal)
++ return { status: 422, body: { error: err.code, message: err.message } }
++ throw err
++ }
++ const version = row.version + 1
++ await db.exec(sql`
++ UPDATE casual_saves SET state = ${sql.json(outcome.state)}, version = ${version},
++ stage = ${outcome.state.stage.cleared}, power = ${teamPower(pack, outcome.state)}, updated_at = ${now}
++ WHERE game_id = ${pack.id} AND account_key = ${key}
++ `)
++ await writeLedger(db, pack.id, key, actionKey, outcome.deltas, now)
++ await db.exec(sql`
++ INSERT INTO casual_actions (game_id, account_key, action_key, response, at)
++ VALUES (${pack.id}, ${key}, ${actionKey}, ${sql.json({ result: outcome.result })}, ${now})
++ `)
++ return { status: 200, body: { state: outcome.state, version, result: outcome.result } }
++ })
++}
++
++export interface BoardEntry {
++ rank: number
++ name: string
++ stage: number
++ power: number
++}
++
++/** The furthest signed-in players: highest stage cleared, then team power, then who got there first. */
++export async function casualLeaderboard(db: Db, game: string, limit = 50): Promise<BoardEntry[]> {
++ const rows = await db.many<{ player_name: string; stage: number; power: number }>(sql`
++ SELECT player_name, stage, power FROM casual_saves
++ WHERE game_id = ${game} AND player_name IS NOT NULL AND stage > 0
++ ORDER BY stage DESC, power DESC, updated_at ASC
++ LIMIT ${limit}
++ `)
++ return rows.map((r, i) => ({ rank: i + 1, name: r.player_name, stage: r.stage, power: r.power }))
++}
++
++export interface LedgerLine {
++ id: number
++ asset: string
++ delta: number
++ balance: number
++ reason: string
++ at: number
++}
++
++/** The player's own ledger, newest first, 100 lines a page. */
++export async function casualLedger(
++ db: Db,
++ game: string,
++ key: string,
++ before: number | null,
++): Promise<{ lines: LedgerLine[]; next: number | null }> {
++ const lines = await db.many<LedgerLine>(sql`
++ SELECT id, asset, delta, balance, reason, at FROM casual_ledger
++ WHERE game_id = ${game} AND account_key = ${key} ${before ? sql`AND id < ${before}` : sql``}
++ ORDER BY id DESC LIMIT 101
++ `)
++ const more = lines.length > 100
++ const page = lines.slice(0, 100)
++ return { lines: page, next: more ? (page[page.length - 1]?.id ?? null) : null }
++}
++
++/** Idempotency records are needed only while a client may retry: a day is plenty. */
++export async function pruneCasualActions(db: Db, now: number): Promise<number> {
++ return db.exec(sql`DELETE FROM casual_actions WHERE at < ${now - 86_400_000}`)
++}
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex d3b9e73..19a05d0 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -69,6 +69,9 @@ import { createWsTicketStore } from './net/wsTicket.js'
import { GameProgressWriter } from './game/progression.js'
import type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
++import { createCasualRoutes } from './casual/routes.js'
++import { pruneCasualActions } from './casual/store.js'
++import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
/**
* Dedicated authoritative server entry point.
@@@ -252,8 +255,22 @@ async function main(): Promise<void> {
networkUrl: config.platform.networkUrl,
log: platformLog.child({ system: 'staff-auth' }),
})
-- const pruneTimer = setInterval(() => void outbox?.prune(), 6 * 3600 * 1000)
++ const pruneTimer = setInterval(
++ () => {
++ void outbox?.prune()
++ void pruneCasualActions(db, Date.now()).catch((err: unknown) =>
++ log.warn('casual action prune failed', { error: String(err) }),
++ )
++ },
++ 6 * 3600 * 1000,
++ )
pruneTimer.unref()
++ // The casual games' packs (plan T22) are validated like the 3D packs: a bad pack never serves.
++ for (const pack of Object.values(CASUAL_GAMES)) {
++ const problems = validatePack(pack)
++ if (problems.length)
++ throw new Error(`casual pack ${pack.id}: ${problems.slice(0, 5).join('; ')}`)
++ }
// games.progress.summary on Network (WS-B task 9): needs the games principal.
const progressSummary = platform
@@@ -674,6 +691,14 @@ async function main(): Promise<void> {
// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
const clientAddress = (req: IncomingMessage): string =>
resolveClientAddress(req, config.trustProxy)
++ // The casual games' JSON API (plan T22).
++ const serveCasual = createCasualRoutes({
++ db,
++ networkAuthUrl: config.networkAuthUrl,
++ limits: actorLimits,
++ address: clientAddress,
++ log: log.child({ system: 'casual' }),
++ })
const http = createHttpServer(
config.staticDir,
metrics,
@@@ -746,6 +771,7 @@ async function main(): Promise<void> {
)
return true
}
++ if (serveCasual(req, res)) return true
if (
handleGameRead(req, res, {
db,
ddiff --git a/apps/server/src/net/actorLimits.ts b/apps/server/src/net/actorLimits.ts
iindex 5ea0f81..1d3fd3b 100644
---- a/apps/server/src/net/actorLimits.ts
++++ b/apps/server/src/net/actorLimits.ts
@@@ -49,6 +49,9 @@ export function createGamesActorLimits(
// flood must not get to spend tickets or hold sockets (ADR-0007 decision 8).
const upgrade = limiter('games.ws.upgrade', { minute: 90, hour: 1800 })
const publicRead = limiter('games.public.read', { minute: 60, hour: 1200 })
++ // Casual games (plan T22): a session open per address, and each player's actions (a tap is one request).
++ const casualSession = limiter('games.casual.session', { minute: 30, hour: 600 })
++ const casualAction = limiter('games.casual.action', { minute: 120, hour: 3000 })
const run =
(mw: ReturnType<typeof limiter>) =>
(req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
@@@ -68,6 +71,8 @@ export function createGamesActorLimits(
ticket: run(ticket),
upgrade: run(upgrade),
publicRead: run(publicRead),
++ casualSession: run(casualSession),
++ casualAction: run(casualAction),
}
}
ddiff --git a/apps/server/src/net/discovery.test.ts b/apps/server/src/net/discovery.test.ts
iindex 0e28dc5..7dcdb00 100644
---- a/apps/server/src/net/discovery.test.ts
++++ b/apps/server/src/net/discovery.test.ts
@@@ -147,6 +147,7 @@ describe('crawl artifacts (plan T11)', () => {
expect(r.headers['content-type']).toBe('application/xml; charset=utf-8')
expect(r.body).toContain('<loc>https://openvibe.games/</loc>')
expect(r.body).toContain('<loc>https://play.openvibe.games/</loc>')
++ expect(r.body).toContain('<loc>https://openvibe.games/play/vibe-heroes</loc>')
// The lastmod is the committed content-revision date, not the clock.
const status = JSON.parse(readFileSync(join(ROOT, 'STATUS.json'), 'utf8')) as {
updated: string
@@@ -163,6 +164,7 @@ describe('crawl artifacts (plan T11)', () => {
expect(r.body).toContain('# OpenVibe.Games')
expect(r.body).toContain('- [OpenVibe.Games home](https://openvibe.games/)')
expect(r.body).toContain('- [Scraplandia](https://play.openvibe.games/)')
++ expect(r.body).toContain('- [Vibe Heroes](https://openvibe.games/play/vibe-heroes)')
expect(r.body).toContain('https://openvibe.games/sitemap.xml')
expect(r.body).toContain('https://openvibe.games/robots.txt')
expect(r.body).toContain('https://openvibe.games/map.json')
@@@ -212,6 +214,8 @@ describe('crawl artifacts (plan T11)', () => {
expect(blocks.map((b) => (JSON.parse(b[1] ?? '{}') as { '@type'?: string })['@type'])).toEqual([
'WebSite',
'WebApplication',
++ // Vibe Heroes, the casual line's first game (plan T22).
++ 'WebApplication',
'WebPage',
])
const nodes = blocks.map((b) => JSON.parse(b[1] ?? '{}') as Record<string, unknown>)
ddiff --git a/apps/server/src/net/discovery.ts b/apps/server/src/net/discovery.ts
iindex fc0d744..ef8402a 100644
---- a/apps/server/src/net/discovery.ts
++++ b/apps/server/src/net/discovery.ts
@@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
++import { CASUAL_GAMES } from '@openvibe/casual'
/**
* Crawl and machine-readability artifacts (plan T11; audit §4 items 1-2): GET /robots.txt,
@@@ -166,6 +167,13 @@ function publicPageDocs(): PublicPageDoc[] {
text: GAME_DESCRIPTION,
priority: 0.9,
},
++ ...Object.values(CASUAL_GAMES).map((pack) => ({
++ title: pack.name,
++ url: `${SITE}/play/${pack.id}`,
++ note: 'a casual game for the phone browser, playable as a guest with nothing to install',
++ text: pack.tagline,
++ priority: 0.8,
++ })),
]
}
@@@ -246,6 +254,15 @@ export function homeJsonLd(): unknown[] {
category: 'GameApplication',
keywords: 'browser game, multiplayer, physics sandbox, survival, building, openvibe',
}),
++ ...Object.values(CASUAL_GAMES).map((pack) =>
++ seo.jsonLd.softwareApp({
++ name: pack.name,
++ url: `${SITE}/play/${pack.id}`,
++ description: pack.tagline,
++ category: 'GameApplication',
++ keywords: 'browser game, idle game, hero collector, mobile, free to play, openvibe',
++ }),
++ ),
seo.jsonLd.webPage({
name: SITE_NAME,
url: `${SITE}/`,
@@@ -309,3 +326,18 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
send(res, 'text/plain', url === '/llms-full.txt' ? llmsFullBody() : llmsBody())
return true
}
++
++/** A casual game's page JSON-LD (plan T22): the game as a GameApplication, as on the home page. */
++export function casualJsonLdTag(packId: string): string {
++ const pack = CASUAL_GAMES[packId]
++ if (!pack) return ''
++ return seo.jsonLdTag(
++ seo.jsonLd.softwareApp({
++ name: pack.name,
++ url: `${SITE}/play/${pack.id}`,
++ description: pack.tagline,
++ category: 'GameApplication',
++ keywords: 'browser game, idle game, hero collector, mobile, free to play, openvibe',
++ }),
++ )
++}
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex f5f82d0..7e0c2ad 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -48,6 +48,7 @@ export function portalHtml(filePath: string): string {
return html
}
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
++import { casualGameOf, casualPageHtml } from '../casual/page.js'
import type { MapFileV2 } from '@openvibe/content'
/**
@@@ -179,6 +180,7 @@ export function createHttpServer(
{ href: '/', label: 'OpenVibe.Games home' },
{ href: '/play', label: 'Play Scraplandia' },
{ href: '/play?game=salvage-rush', label: 'Play Salvage Rush' },
++ { href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },
{ href: '/leaderboard.html', label: 'Leaderboard' },
{ href: '/editor', label: 'Map editor' },
]
@@@ -723,6 +725,32 @@ export function createHttpServer(
// game and /editor the map editor. On the play host the game IS the
// root page (with /play redirecting there so old links keep working).
// The old .html URLs and a trailing slash redirect to the canonical URL.
++ // A casual game (plan T22): /play/<game> is the page an ad or a shared link opens. Its query (src, c) is
++ // kept through the trailing-slash redirect, because that is the campaign.
++ const casualId = casualGameOf(url)
++ if (casualId) {
++ if (url.endsWith('/')) {
++ res.writeHead(301, { location: url.slice(0, -1) + (req.url ?? '').slice(url.length) })
++ res.end()
++ return
++ }
++ const page = join(root, 'casual.html')
++ if (!existsSync(page)) {
++ res.writeHead(404)
++ res.end('client build missing')
++ return
++ }
++ res.writeHead(200, {
++ 'content-type': 'text/html; charset=utf-8',
++ 'cache-control': 'no-cache',
++ })
++ res.end(casualPageHtml(page, casualId))
++ return
++ }
++ if (url === '/casual.html') {
++ notFound()
++ return
++ }
if (url === '/play.html' || url === '/editor.html') {
res.writeHead(301, {
location: url === '/play.html' ? (playHost ? '/' : '/play') : '/editor',
ddiff --git a/apps/server/src/net/wsTransport.test.ts b/apps/server/src/net/wsTransport.test.ts
iindex 8f6148e..16dc310 100644
---- a/apps/server/src/net/wsTransport.test.ts
++++ b/apps/server/src/net/wsTransport.test.ts
@@@ -65,6 +65,8 @@ function openLimits() {
modWrite: () => Promise.resolve(true),
ticket: () => Promise.resolve(true),
publicRead: () => Promise.resolve(true),
++ casualSession: () => Promise.resolve(true),
++ casualAction: () => Promise.resolve(true),
upgrade: (req: unknown, _res: unknown, actor: string) =>
new Promise<boolean>((resolve) => {
;(req as { ovActor?: string }).ovActor = actor
ddiff --git a/apps/server/src/platform/accountData.test.ts b/apps/server/src/platform/accountData.test.ts
iindex 82f5558..d6c33e7 100644
---- a/apps/server/src/platform/accountData.test.ts
++++ b/apps/server/src/platform/accountData.test.ts
@@@ -114,7 +114,7 @@ describe('account export and deletion (ADR-033)', () => {
'/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8/confirmations',
)
expect(validate('network.account-deletion-confirmation@1', conf.body).valid).toBe(true)
-- expect(conf.body.erased).toEqual({ characters: 2, identity_rows: 0 })
++ expect(conf.body.erased).toEqual({ characters: 2, identity_rows: 0, casual_saves: 0 })
expect(conf.body.retained).toEqual({ structures_unowned: 0 })
expect(await data.apply(del)).toBe('unchanged')
expect(sent.length).toBe(2)
ddiff --git a/apps/server/src/platform/accountData.ts b/apps/server/src/platform/accountData.ts
iindex 24bfd98..6164b8c 100644
---- a/apps/server/src/platform/accountData.ts
++++ b/apps/server/src/platform/accountData.ts
@@@ -78,6 +78,12 @@ export function createAccountData(opts: {
? [{ name: 'game-achievements.json', content: data.gameAchievements }]
: []),
...(data.gameHauls.length ? [{ name: 'game-hauls.json', content: data.gameHauls }] : []),
++ ...(data.casualSaves.length
++ ? [{ name: 'casual-saves.json', content: data.casualSaves }]
++ : []),
++ ...(data.casualLedger.length
++ ? [{ name: 'casual-ledger.json', content: data.casualLedger }]
++ : []),
]
const res = await opts.send(`/internal/account-exports/${id}/parts`, { subject, files })
const outcome = res.ok
ddiff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json
iindex 86b8bf4..78928c2 100644
---- a/apps/server/tsconfig.json
++++ b/apps/server/tsconfig.json
@@@ -10,6 +10,7 @@
"include": ["src"],
"references": [
{ "path": "../../packages/shared" },
++ { "path": "../../packages/casual" },
{ "path": "../../packages/protocol" },
{ "path": "../../packages/content" },
{ "path": "../../packages/gameplay" },
ddiff --git a/docs/casual.md b/docs/casual.md
nnew file mode 100644
iindex 0000000..a730b6f
---- /dev/null
++++ b/docs/casual.md
@@@ -0,0 +1,69 @@
++# The casual line (plan T22)
++
++Small browser games that an ad or a shared link opens straight into play on a phone, with nothing to install. Each
++game is a content pack on one shared kit; the first is **Vibe Heroes**, an idle hero collector, at
++`https://openvibe.games/play/vibe-heroes`.
++
++## Pieces
++
++| Where | What |
++| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
++| `packages/casual` | The kit, engine-free: types, the content pack (`content/vibeHeroes.ts`), hero and enemy stats, the auto-battle, the rules as one reducer (`apply`), read-only views (energy, idle chest, featured heroes, odds, red dots), pack validation and digest. Server and page both import it. |
++| `packages/persistence/migrations/0007_casual.sql` | `casual_saves` (one save per game per account), `casual_ledger` (every currency and item change), `casual_actions` (idempotency records). |
++| `apps/server/src/casual` | The economy (`store.ts`), the JSON API (`routes.ts`), the page with its share card (`page.ts`). |
++| `apps/client/casual.html`, `apps/client/src/casual` | The page: a phone-first DOM client with no 3D engine, under 30 KB of gzip JS. |
++| `apps/client/scripts/casualCard.ts` | Draws the 1200×630 share card into `public/casual/<game>/card.png`. |
++
++## The server decides
++
++The client sends intents; the server applies them inside one database transaction:
++
++1. lock the player's save (`SELECT … FOR UPDATE`);
++2. answer the stored result if this idempotency key was applied before (a retried request never pays twice);
++3. run the kit's reducer on a copy of the state (a refusal changes nothing and answers 422 with its code);
++4. write the new state, one ledger line per currency or item change, and the action record.
++
++Battles are fought on the server with a fresh random seed and sent to the page as a log, which the page only plays
++back. Stage rewards are fixed (no random drops), so energy refills never buy a chance.
++
++## API
++
++All under `/api/v1/casual/:game`. Identity is a Network session (`Authorization: Bearer`) or the browser's Games
++guest token (`X-Guest-Token`), the same two the 3D games use. A signed-in request that still carries the guest
++token takes over that guest's save when the account has none for that game.
++
++| Method and path | Answer |
++| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
++| `POST …/session` `{ src?, c? }` | `{ state, version, created, now, digest, player }`. The save is created on the first visit, and the ad link's `src` and `c` are kept then only. |
++| `POST …/actions` `{ key, action }` + `X-Casual-Digest` | `{ state, version, result, now }`, `replay: true` for a repeated key; 409 `content_changed` when the page is older than the server's pack; 422 with the refusal's code. |
++| `GET …/leaderboard` | The 50 furthest signed-in players (highest stage, then team power). Guests are never listed. |
++| `GET …/ledger?before=` | The player's own ledger, newest first, 100 lines a page. |
++
++Actions: `collect`, `battle {stage}`, `sweep {stage, times}`, `team {heroes}`, `level {hero, times}`,
++`star {hero}`, `fuse {hero}`, `equip {hero, slot, gear}`, `summon {count: 1|10}`, `quest {quest}`, `login`,
++`buy {product}`. Limits: `games.casual.session` 30/min per address, `games.casual.action` 120/min per player.
++
++## Money and chance
++
++- **Gems** are the premium currency. The wallet keeps gems earned in play (`gems`) apart from gems bought with money
++ (`gemsPaid`); spending takes earned gems first. Gems never turn into Vibes and are never cashed out.
++- **Nothing is sold for money yet.** Paid products (gem packs, a starter pack, a monthly card, a season pass) arrive
++ with OpenVibe.Billing's checkout (plan T5, owner item O1); until then the store sells only for gems.
++- **Summons use sigils**, which are earned in play (quests, logins, stages) and never sold. The odds of every hero
++ are published in the game, a legendary is certain within 50 draws (the counter is shown), and ten at once always
++ hold an epic or better. Paid random draws need the owner's decision (O37; ADR-055 proposes never paid chance).
++- **Every product lists exactly what it gives**, including the weekly featured-shard bundles.
++
++## Getting players
++
++- The landing route is `/play/<game>?src=<source>&c=<campaign>`. The page sends `src` and `c` with the first visit
++ and removes them from the address bar; they are kept on the save (first-party only) for campaign numbers.
++- The page's share card (Open Graph and X `summary_large_image`) and its JSON-LD are filled in by the server.
++- No third-party pixel runs. X's pixel waits for the owner's ads account and pixel id (O36) and a consent prompt.
++
++## Adding a game
++
++1. Write a `CasualPack` in `packages/casual/src/content/` and add it to `CASUAL_GAMES`.
++2. `validatePack` must pass (the server refuses to start otherwise).
++3. Run `node --import tsx apps/client/scripts/casualCard.ts <game>` for its share card.
++4. Its page is `/play/<game>`; the sitemap, `/llms.txt` and the home page's JSON-LD list it automatically.
ddiff --git a/packages/casual/package.json b/packages/casual/package.json
nnew file mode 100644
iindex 0000000..95ad581
---- /dev/null
++++ b/packages/casual/package.json
@@@ -0,0 +1,15 @@
++{
++ "name": "@openvibe/casual",
++ "version": "0.1.0",
++ "private": true,
++ "type": "module",
++ "exports": {
++ ".": "./src/index.ts"
++ },
++ "scripts": {
++ "build": "tsc --build"
++ },
++ "dependencies": {
++ "@openvibe/shared": "workspace:*"
++ }
++}
ddiff --git a/packages/casual/src/actions.ts b/packages/casual/src/actions.ts
nnew file mode 100644
iindex 0000000..243c2fd
---- /dev/null
++++ b/packages/casual/src/actions.ts
@@@ -0,0 +1,573 @@
++/**
++ * The casual kit's rules as one reducer. The server applies an action to a copy of the player's state inside a
++ * database transaction and stores the result together with its ledger lines; the client never decides a reward,
++ * a drop or a price. A refused action throws `Refusal` and changes nothing.
++ */
++import type { Rng } from '@openvibe/shared'
++import { DEFAULT_POWER, simulate, starsFor, type BattleLog, type UnitSpec } from './battle.js'
++import { energyNow, featuredFor, goldPerMinute, heroDef, idlePending } from './kit.js'
++import { BASE_STARS, enemyStats, heroStats, levelCap, levelCost } from './stats.js'
++import { dayKey, weekKey } from './time.js'
++import type {
++ CasualPack,
++ Counter,
++ Currency,
++ Delta,
++ GearSlot,
++ PlayerState,
++ ProductDef,
++ Rarity,
++ Reward,
++} from './types.js'
++
++export type Action =
++ | { t: 'collect' }
++ | { t: 'battle'; stage: number }
++ | { t: 'sweep'; stage: number; times: number }
++ | { t: 'team'; heroes: string[] }
++ | { t: 'level'; hero: string; times: number }
++ | { t: 'star'; hero: string }
++ | { t: 'fuse'; hero: string }
++ | { t: 'equip'; hero: string; slot: GearSlot; gear: string | null }
++ | { t: 'summon'; count: 1 | 10 }
++ | { t: 'quest'; quest: string }
++ | { t: 'login' }
++ | { t: 'buy'; product: string }
++
++export class Refusal extends Error {
++ constructor(
++ readonly code: string,
++ message: string,
++ ) {
++ super(message)
++ }
++}
++
++export interface Outcome {
++ state: PlayerState
++ deltas: Delta[]
++ result: Record<string, unknown>
++}
++
++const ID = /^[a-z0-9_]{1,40}$/
++const SLOTS: GearSlot[] = ['weapon', 'armor', 'charm']
++const int = (v: unknown, min: number, max: number): number | null =>
++ typeof v === 'number' && Number.isSafeInteger(v) && v >= min && v <= max ? v : null
++const id = (v: unknown): string | null => (typeof v === 'string' && ID.test(v) ? v : null)
++
++/** A request body's action, or null when it is not exactly one of the shapes above. */
++export function parseAction(raw: unknown): Action | null {
++ if (!raw || typeof raw !== 'object') return null
++ const a = raw as Record<string, unknown>
++ switch (a.t) {
++ case 'collect':
++ case 'login':
++ return { t: a.t }
++ case 'battle': {
++ const stage = int(a.stage, 1, 10_000)
++ return stage === null ? null : { t: 'battle', stage }
++ }
++ case 'sweep': {
++ const stage = int(a.stage, 1, 10_000)
++ const times = int(a.times, 1, 10)
++ return stage === null || times === null ? null : { t: 'sweep', stage, times }
++ }
++ case 'team': {
++ if (!Array.isArray(a.heroes) || a.heroes.length > 10) return null
++ const heroes = a.heroes.map(id)
++ return heroes.every((h): h is string => h !== null) ? { t: 'team', heroes } : null
++ }
++ case 'level': {
++ const hero = id(a.hero)
++ const times = int(a.times, 1, 200)
++ return hero === null || times === null ? null : { t: 'level', hero, times }
++ }
++ case 'star':
++ case 'fuse': {
++ const hero = id(a.hero)
++ return hero === null ? null : { t: a.t, hero }
++ }
++ case 'equip': {
++ const hero = id(a.hero)
++ const slot = SLOTS.find((s) => s === a.slot)
++ const gear = a.gear === null ? null : id(a.gear)
++ if (hero === null || !slot || (a.gear !== null && gear === null)) return null
++ return { t: 'equip', hero, slot, gear }
++ }
++ case 'summon':
++ return a.count === 1 || a.count === 10 ? { t: 'summon', count: a.count } : null
++ case 'quest': {
++ const quest = id(a.quest)
++ return quest === null ? null : { t: 'quest', quest }
++ }
++ case 'buy': {
++ const product = id(a.product)
++ return product === null ? null : { t: 'buy', product }
++ }
++ default:
++ return null
++ }
++}
++
++/** A new player: the pack's starting heroes in the team, its starting wallet, full energy. */
++export function newPlayer(pack: CasualPack, now: number): Outcome {
++ const s: PlayerState = {
++ v: 1,
++ created: now,
++ wallet: { gold: 0, gems: 0, gemsPaid: 0, sigils: 0 },
++ energy: { value: pack.energy.max, at: now },
++ heroes: {},
++ shards: {},
++ gear: {},
++ team: [],
++ stage: { cleared: 0, stars: {} },
++ idle: { at: now },
++ quests: { day: dayKey(now), week: weekKey(now), progress: {}, claimed: [] },
++ login: { last: null, count: 0 },
++ summon: { pity: 0, total: 0 },
++ buys: {},
++ stats: { battles: 0, wins: 0 },
++ }
++ const deltas: Delta[] = []
++ for (const h of pack.start.heroes) {
++ const def = heroDef(pack, h)
++ if (!def) continue
++ s.heroes[h] = { level: 1, stars: BASE_STARS[def.rarity], gear: {} }
++ deltas.push({ asset: `hero:${h}`, delta: 1, balance: 1, reason: 'start' })
++ if (s.team.length < pack.teamSize) s.team.push(h)
++ }
++ for (const [c, n] of Object.entries(pack.start.wallet) as [Currency, number][]) {
++ credit(s, deltas, c, n, 'start')
++ }
++ return { state: s, deltas, result: {} }
++}
++
++function credit(s: PlayerState, deltas: Delta[], c: Currency, n: number, reason: string): void {
++ if (n === 0) return
++ s.wallet[c] += n
++ deltas.push({ asset: c, delta: n, balance: s.wallet[c], reason })
++}
++
++function spend(
++ s: PlayerState,
++ deltas: Delta[],
++ c: 'gold' | 'sigils',
++ n: number,
++ reason: string,
++): void {
++ if (s.wallet[c] < n) throw new Refusal(`not_enough_${c}`, `That needs ${n} ${c}.`)
++ credit(s, deltas, c, -n, reason)
++}
++
++/** Gems earned in play go first, then gems bought with money. */
++function spendGems(s: PlayerState, deltas: Delta[], n: number, reason: string): void {
++ if (s.wallet.gems + s.wallet.gemsPaid < n)
++ throw new Refusal('not_enough_gems', `That needs ${n} gems.`)
++ const free = Math.min(s.wallet.gems, n)
++ credit(s, deltas, 'gems', -free, reason)
++ credit(s, deltas, 'gemsPaid', -(n - free), reason)
++}
++
++function addShards(s: PlayerState, deltas: Delta[], hero: string, n: number, reason: string): void {
++ if (n === 0) return
++ const balance = (s.shards[hero] ?? 0) + n
++ if (balance === 0) delete s.shards[hero]
++ else s.shards[hero] = balance
++ deltas.push({ asset: `shard:${hero}`, delta: n, balance, reason })
++}
++
++function addGear(s: PlayerState, deltas: Delta[], gear: string, n: number, reason: string): void {
++ const balance = (s.gear[gear] ?? 0) + n
++ if (balance === 0) delete s.gear[gear]
++ else s.gear[gear] = balance
++ deltas.push({ asset: `gear:${gear}`, delta: n, balance, reason })
++}
++
++/** Energy settled to `now` (refills counted in), then changed by `n`. */
++function changeEnergy(pack: CasualPack, s: PlayerState, n: number, now: number): void {
++ const current = energyNow(pack, s, now)
++ if (current >= pack.energy.max) s.energy.at = now
++ else {
++ const regen = pack.energy.regenMs
++ s.energy.at += Math.max(0, Math.floor((now - s.energy.at) / regen)) * regen
++ }
++ s.energy.value = current + n
++}
++
++function grant(
++ pack: CasualPack,
++ s: PlayerState,
++ deltas: Delta[],
++ reward: Reward,
++ reason: string,
++ now: number,
++): void {
++ if (reward.gold) credit(s, deltas, 'gold', reward.gold, reason)
++ if (reward.idleMinutes)
++ credit(s, deltas, 'gold', reward.idleMinutes * goldPerMinute(pack, s), reason)
++ if (reward.gems) credit(s, deltas, 'gems', reward.gems, reason)
++ if (reward.sigils) credit(s, deltas, 'sigils', reward.sigils, reason)
++ if (reward.energy) changeEnergy(pack, s, reward.energy, now)
++ for (const sh of reward.shards ?? []) addShards(s, deltas, sh.hero, sh.n, reason)
++ for (const g of reward.gear ?? []) addGear(s, deltas, g, 1, reason)
++}
++
++/** Starts a new day or week of quests when one has begun. */
++function rollQuests(pack: CasualPack, s: PlayerState, now: number): void {
++ const day = dayKey(now)
++ const week = weekKey(now)
++ const q = s.quests
++ const drop = (period: 'daily' | 'weekly', prefix: string) => {
++ const ids = new Set(pack.quests.filter((x) => x.period === period).map((x) => x.id))
++ q.claimed = q.claimed.filter((c) => !ids.has(c))
++ for (const k of Object.keys(q.progress)) if (k.startsWith(prefix)) delete q.progress[k]
++ }
++ if (q.day !== day) {
++ drop('daily', 'd:')
++ q.day = day
++ }
++ if (q.week !== week) {
++ drop('weekly', 'w:')
++ q.week = week
++ }
++}
++
++function bump(s: PlayerState, counter: Counter, n: number): void {
++ if (n <= 0) return
++ for (const p of ['d', 'w']) {
++ const k = `${p}:${counter}`
++ s.quests.progress[k] = (s.quests.progress[k] ?? 0) + n
++ }
++}
++
++function owned(s: PlayerState, hero: string) {
++ const hs = s.heroes[hero]
++ if (!hs) throw new Refusal('not_owned', 'You do not have that hero yet.')
++ return hs
++}
++
++function teamSpecs(pack: CasualPack, s: PlayerState): UnitSpec[] {
++ const out: UnitSpec[] = []
++ for (const id of s.team) {
++ const def = heroDef(pack, id)
++ const hs = s.heroes[id]
++ if (!def || !hs) continue
++ out.push({
++ id,
++ cls: def.class,
++ element: def.element,
++ stats: heroStats(pack, def, hs),
++ power: def.skill.power,
++ })
++ }
++ if (!out.length) throw new Refusal('team_empty', 'Put at least one hero in your team.')
++ return out
++}
++
++function stageSpecs(pack: CasualPack, index: number): UnitSpec[] {
++ const stage = pack.stages[index - 1]
++ if (!stage) throw new Refusal('unknown_stage', 'There is no such stage.')
++ return stage.enemies.map((eid) => {
++ const e = pack.enemies.find((x) => x.id === eid)
++ if (!e) throw new Refusal('unknown_stage', 'There is no such stage.')
++ return {
++ id: e.id,
++ cls: e.class,
++ element: e.element,
++ stats: enemyStats(e.class, stage.level, stage.index, e.boss),
++ power: DEFAULT_POWER[e.class],
++ boss: e.boss,
++ }
++ })
++}
++
++function periodKey(period: 'daily' | 'weekly' | 'once', now: number): string {
++ return period === 'daily' ? dayKey(now) : period === 'weekly' ? weekKey(now) : 'once'
++}
++
++/** Applies one action to a copy of `state`. */
++export function apply(
++ pack: CasualPack,
++ state: PlayerState,
++ action: Action,
++ now: number,
++ rng: Rng,
++): Outcome {
++ const s = structuredClone(state)
++ const deltas: Delta[] = []
++ rollQuests(pack, s, now)
++ const done = (result: Record<string, unknown> = {}): Outcome => ({ state: s, deltas, result })
++
++ switch (action.t) {
++ case 'collect': {
++ const p = idlePending(pack, s, now)
++ if (p.minutes < 1) throw new Refusal('not_ready', 'Nothing has piled up yet.')
++ credit(s, deltas, 'gold', p.gold, 'idle')
++ credit(s, deltas, 'gems', p.gems, 'idle')
++ s.idle.at = now
++ bump(s, 'idle_collected', 1)
++ return done({ minutes: p.minutes, gold: p.gold, gems: p.gems })
++ }
++
++ case 'battle': {
++ const stage = pack.stages[action.stage - 1]
++ if (!stage) throw new Refusal('unknown_stage', 'There is no such stage.')
++ if (action.stage > s.stage.cleared + 1)
++ throw new Refusal('locked_stage', 'Clear the stage before it first.')
++ const first = action.stage === s.stage.cleared + 1
++ const cost = first && action.stage <= pack.freeFirstClears ? 0 : stage.energy
++ if (energyNow(pack, s, now) < cost)
++ throw new Refusal('not_enough_energy', `That needs ${cost} energy.`)
++ const log: BattleLog = simulate(teamSpecs(pack, s), stageSpecs(pack, action.stage), rng)
++ s.stats.battles++
++ // A lost battle costs one energy; the rest comes back.
++ const spent = log.won ? cost : Math.min(cost, 1)
++ if (spent) {
++ changeEnergy(pack, s, -spent, now)
++ bump(s, 'energy_spent', spent)
++ }
++ const stars = starsFor(log)
++ let reward: Reward = {}
++ if (log.won) {
++ s.stats.wins++
++ bump(s, 'battle_won', 1)
++ reward = first ? stage.first : stage.repeat
++ grant(
++ pack,
++ s,
++ deltas,
++ reward,
++ first ? `stage:${stage.index}:first` : `stage:${stage.index}`,
++ now,
++ )
++ if (first) {
++ s.stage.cleared = stage.index
++ bump(s, 'stage_cleared', 1)
++ }
++ const key = String(stage.index)
++ s.stage.stars[key] = Math.max(s.stage.stars[key] ?? 0, stars)
++ }
++ return done({
++ battle: log,
++ won: log.won,
++ stars,
++ first: first && log.won,
++ reward,
++ energy: spent,
++ })
++ }
++
++ case 'sweep': {
++ const stage = pack.stages[action.stage - 1]
++ if (!stage || action.stage > s.stage.cleared)
++ throw new Refusal('locked_stage', 'Clear the stage first.')
++ if ((s.stage.stars[String(stage.index)] ?? 0) < 3)
++ throw new Refusal('needs_three_stars', 'Win the stage with three stars to sweep it.')
++ const cost = stage.energy * action.times
++ if (energyNow(pack, s, now) < cost)
++ throw new Refusal('not_enough_energy', `That needs ${cost} energy.`)
++ changeEnergy(pack, s, -cost, now)
++ bump(s, 'energy_spent', cost)
++ for (let i = 0; i < action.times; i++)
++ grant(pack, s, deltas, stage.repeat, `sweep:${stage.index}`, now)
++ s.stats.battles += action.times
++ s.stats.wins += action.times
++ bump(s, 'battle_won', action.times)
++ return done({ times: action.times, energy: cost })
++ }
++
++ case 'team': {
++ const unique = [...new Set(action.heroes)]
++ if (unique.length !== action.heroes.length || unique.length > pack.teamSize)
++ throw new Refusal('bad_team', `A team is up to ${pack.teamSize} different heroes.`)
++ if (!unique.length) throw new Refusal('team_empty', 'Put at least one hero in your team.')
++ for (const h of unique) owned(s, h)
++ s.team = unique
++ return done()
++ }
++
++ case 'level': {
++ const def = heroDef(pack, action.hero)
++ const hs = owned(s, action.hero)
++ if (!def) throw new Refusal('unknown_hero', 'There is no such hero.')
++ const cap = levelCap(pack, hs.stars)
++ if (hs.level >= cap)
++ throw new Refusal('max_level', 'Star this hero up to raise the level cap.')
++ let gained = 0
++ let cost = 0
++ while (gained < action.times && hs.level + gained < cap) {
++ const next = levelCost(hs.level + gained)
++ if (s.wallet.gold < cost + next) break
++ cost += next
++ gained++
++ }
++ if (!gained)
++ throw new Refusal('not_enough_gold', `The next level needs ${levelCost(hs.level)} gold.`)
++ spend(s, deltas, 'gold', cost, `level:${action.hero}`)
++ hs.level += gained
++ bump(s, 'hero_levelled', gained)
++ return done({ levels: gained, gold: cost })
++ }
++
++ case 'star': {
++ const def = heroDef(pack, action.hero)
++ const hs = owned(s, action.hero)
++ if (!def) throw new Refusal('unknown_hero', 'There is no such hero.')
++ if (hs.stars >= pack.maxStars) throw new Refusal('max_stars', 'This hero has every star.')
++ const shards = pack.starShards[hs.stars] ?? Infinity
++ const gold = pack.starGold[hs.stars] ?? Infinity
++ if ((s.shards[action.hero] ?? 0) < shards)
++ throw new Refusal('not_enough_shards', `That needs ${shards} shards.`)
++ spend(s, deltas, 'gold', gold, `star:${action.hero}`)
++ addShards(s, deltas, action.hero, -shards, `star:${action.hero}`)
++ hs.stars++
++ bump(s, 'star_up', 1)
++ return done({ stars: hs.stars })
++ }
++
++ case 'fuse': {
++ const def = heroDef(pack, action.hero)
++ if (!def) throw new Refusal('unknown_hero', 'There is no such hero.')
++ if (s.heroes[action.hero]) throw new Refusal('already_owned', 'You already have this hero.')
++ const need = pack.fuseShards[def.rarity]
++ if ((s.shards[action.hero] ?? 0) < need)
++ throw new Refusal('not_enough_shards', `That needs ${need} shards.`)
++ addShards(s, deltas, action.hero, -need, `fuse:${action.hero}`)
++ s.heroes[action.hero] = { level: 1, stars: BASE_STARS[def.rarity], gear: {} }
++ deltas.push({ asset: `hero:${action.hero}`, delta: 1, balance: 1, reason: 'fuse' })
++ if (s.team.length < pack.teamSize) s.team.push(action.hero)
++ return done({ hero: action.hero })
++ }
++
++ case 'equip': {
++ const hs = owned(s, action.hero)
++ const current = hs.gear[action.slot]
++ if (action.gear !== null) {
++ const g = pack.gear.find((x) => x.id === action.gear)
++ if (!g || g.slot !== action.slot)
++ throw new Refusal('bad_gear', 'That does not go in this slot.')
++ if ((s.gear[g.id] ?? 0) < 1) throw new Refusal('not_owned', 'You do not have that item.')
++ s.gear[g.id] = (s.gear[g.id] ?? 0) - 1
++ if (s.gear[g.id] === 0) delete s.gear[g.id]
++ hs.gear[action.slot] = g.id
++ } else delete hs.gear[action.slot]
++ if (current) s.gear[current] = (s.gear[current] ?? 0) + 1
++ return done()
++ }
++
++ case 'summon': {
++ const b = pack.banner
++ spend(s, deltas, 'sigils', b.cost * action.count, 'summon')
++ const featured = featuredFor(pack, now)
++ const pulls: { hero: string; rarity: Rarity; new: boolean; shards: number }[] = []
++ for (let i = 0; i < action.count; i++) {
++ s.summon.pity++
++ s.summon.total++
++ let rarity: Rarity
++ const r = rng.next()
++ if (s.summon.pity >= b.pity) rarity = 'legendary'
++ else if (r < b.odds.legendary) rarity = 'legendary'
++ else if (r < b.odds.legendary + b.odds.epic) rarity = 'epic'
++ else if (r < b.odds.legendary + b.odds.epic + b.odds.rare) rarity = 'rare'
++ else rarity = 'common'
++ // Ten at once always holds an epic or better: the last draw is lifted when none came.
++ if (
++ action.count === 10 &&
++ i === 9 &&
++ (rarity === 'common' || rarity === 'rare') &&
++ !pulls.some((p) => p.rarity === 'epic' || p.rarity === 'legendary')
++ ) {
++ rarity =
++ rng.next() < b.odds.legendary / (b.odds.legendary + b.odds.epic) ? 'legendary' : 'epic'
++ }
++ if (rarity === 'legendary') s.summon.pity = 0
++ const list = pack.heroes.filter((h) => h.rarity === rarity)
++ const pick =
++ rarity === 'legendary' ? featured.legendary : rarity === 'epic' ? featured.epic : null
++ let hero: string
++ if (pick && list.length > 1 && rng.next() < 0.5) hero = pick
++ else {
++ const others = pick && list.length > 1 ? list.filter((h) => h.id !== pick) : list
++ hero = others[Math.floor(rng.next() * others.length)]?.id ?? list[0]?.id ?? ''
++ }
++ const def = heroDef(pack, hero)
++ if (!def) continue
++ if (!s.heroes[hero]) {
++ s.heroes[hero] = { level: 1, stars: BASE_STARS[def.rarity], gear: {} }
++ deltas.push({ asset: `hero:${hero}`, delta: 1, balance: 1, reason: 'summon' })
++ if (s.team.length < pack.teamSize) s.team.push(hero)
++ pulls.push({ hero, rarity, new: true, shards: 0 })
++ } else {
++ const n = b.duplicateShards[rarity]
++ addShards(s, deltas, hero, n, 'summon')
++ pulls.push({ hero, rarity, new: false, shards: n })
++ }
++ }
++ bump(s, 'summoned', action.count)
++ return done({ pulls, pity: s.summon.pity })
++ }
++
++ case 'quest': {
++ const q = pack.quests.find((x) => x.id === action.quest)
++ if (!q) throw new Refusal('unknown_quest', 'There is no such quest.')
++ if (s.quests.claimed.includes(q.id)) throw new Refusal('already_claimed', 'Already claimed.')
++ const key = `${q.period === 'daily' ? 'd' : 'w'}:${q.counter}`
++ if ((s.quests.progress[key] ?? 0) < q.target) throw new Refusal('not_ready', 'Not done yet.')
++ s.quests.claimed.push(q.id)
++ grant(pack, s, deltas, q.reward, `quest:${q.id}`, now)
++ return done({ reward: q.reward })
++ }
++
++ case 'login': {
++ const day = dayKey(now)
++ if (s.login.last === day) throw new Refusal('already_claimed', 'Come back tomorrow.')
++ const reward = pack.calendar[s.login.count % pack.calendar.length] ?? {}
++ grant(pack, s, deltas, reward, `login:${(s.login.count % pack.calendar.length) + 1}`, now)
++ s.login.count++
++ s.login.last = day
++ bump(s, 'login', 1)
++ return done({ reward, day: s.login.count })
++ }
++
++ case 'buy': {
++ const p = pack.store.find((x) => x.id === action.product)
++ if (!p) throw new Refusal('unknown_product', 'There is no such product.')
++ const key = periodKey(p.limit.period, now)
++ const bought = boughtThisPeriod(s, p, now)
++ if (bought >= p.limit.count)
++ throw new Refusal('limit_reached', 'You have bought all of these for now.')
++ spendGems(s, deltas, p.gems, `buy:${p.id}`)
++ const reward = productReward(pack, s, p.id, now) ?? {}
++ grant(pack, s, deltas, reward, `buy:${p.id}`, now)
++ s.buys[p.id] = { key, n: bought + 1 }
++ return done({ reward })
++ }
++ }
++}
++
++/** How many of a product the player bought in its current period (today, this week, or ever). */
++export function boughtThisPeriod(s: PlayerState, p: ProductDef, now: number): number {
++ const b = s.buys[p.id]
++ return b && b.key === periodKey(p.limit.period, now) ? b.n : 0
++}
++
++/** What a product gives this player now, in plain amounts (its contents are listed before buying). */
++export function productReward(
++ pack: CasualPack,
++ s: PlayerState,
++ productId: string,
++ now: number,
++): Reward | null {
++ const p = pack.store.find((x) => x.id === productId)
++ if (!p) return null
++ if (!p.featured) {
++ const { idleMinutes, ...rest } = p.reward
++ return idleMinutes
++ ? { ...rest, gold: (rest.gold ?? 0) + idleMinutes * goldPerMinute(pack, s) }
++ : rest
++ }
++ const f = featuredFor(pack, now)
++ return {
++ shards: [{ hero: p.featured.rarity === 'legendary' ? f.legendary : f.epic, n: p.featured.n }],
++ }
++}
ddiff --git a/packages/casual/src/battle.ts b/packages/casual/src/battle.ts
nnew file mode 100644
iindex 0000000..4db86b3
---- /dev/null
++++ b/packages/casual/src/battle.ts
@@@ -0,0 +1,248 @@
++/**
++ * The auto-battle. The server runs it and sends the log; the client only plays the log back. Every round each
++ * living unit acts once, fastest first. A basic attack charges 30 energy and taking a hit charges 10; at 100 the
++ * unit uses its class skill instead. A side wins when the other has no one standing; after `maxRounds` the
++ * attackers lose.
++ */
++import type { Rng } from '@openvibe/shared'
++import { advantage, type Stats } from './stats.js'
++import type { Element, HeroClass } from './types.js'
++
++export interface UnitSpec {
++ id: string
++ cls: HeroClass
++ element: Element
++ stats: Stats
++ /** The class skill's strength (SkillDef.power); enemies use their class default. */
++ power: number
++ boss?: boolean
++}
++
++/** One target's outcome in an event. */
++export interface Hit {
++ /** `a0`…`a4` for the attackers, `d0`… for the defenders. */
++ r: string
++ dmg?: number
++ heal?: number
++ shield?: number
++ /** Damage a shield took instead. */
++ blocked?: number
++ crit?: 1
++ /** 1 = advantage, -1 = disadvantage. */
++ adv?: 1 | -1
++ ko?: 1
++}
++
++export interface BattleEvent {
++ round: number
++ actor: string
++ /** `attack` is a basic attack; the rest are class skills. */
++ kind: 'attack' | 'strike' | 'nova' | 'snipe' | 'heal' | 'shield' | 'rally'
++ hits: Hit[]
++}
++
++export interface BattleUnitView {
++ r: string
++ id: string
++ hp: number
++ boss?: 1
++}
++
++export interface BattleLog {
++ units: BattleUnitView[]
++ events: BattleEvent[]
++ won: boolean
++ rounds: number
++ /** Attackers knocked out by the end. */
++ lost: number
++}
++
++export const SKILL_OF: Record<HeroClass, BattleEvent['kind']> = {
++ guardian: 'shield',
++ blade: 'strike',
++ arcanist: 'nova',
++ ranger: 'snipe',
++ mender: 'heal',
++ herald: 'rally',
++}
++
++/** The class skill's default strength, for enemies and as the guide for heroes. */
++export const DEFAULT_POWER: Record<HeroClass, number> = {
++ guardian: 0.12,
++ blade: 2.6,
++ arcanist: 1.1,
++ ranger: 3,
++ mender: 1,
++ herald: 0.3,
++}
++
++interface Unit {
++ r: string
++ side: 0 | 1
++ pos: number
++ cls: HeroClass
++ element: Element
++ maxHp: number
++ hp: number
++ atk: number
++ def: number
++ spd: number
++ power: number
++ energy: number
++ shield: number
++ rally: number
++ rallyRounds: number
++}
++
++const FRONT: Record<HeroClass, number> = {
++ guardian: 0,
++ blade: 1,
++ herald: 2,
++ ranger: 2,
++ arcanist: 2,
++ mender: 2,
++}
++
++function makeUnits(specs: UnitSpec[], side: 0 | 1): Unit[] {
++ return specs.map((s, pos) => ({
++ r: `${side === 0 ? 'a' : 'd'}${pos}`,
++ side,
++ pos,
++ cls: s.cls,
++ element: s.element,
++ maxHp: s.stats.hp,
++ hp: s.stats.hp,
++ atk: s.stats.atk,
++ def: s.stats.def,
++ spd: s.stats.spd,
++ power: s.power,
++ energy: 0,
++ shield: 0,
++ rally: 0,
++ rallyRounds: 0,
++ }))
++}
++
++export function simulate(
++ attackers: UnitSpec[],
++ defenders: UnitSpec[],
++ rng: Rng,
++ maxRounds = 15,
++): BattleLog {
++ const a = makeUnits(attackers, 0)
++ const d = makeUnits(defenders, 1)
++ const all = [...a, ...d]
++ const events: BattleEvent[] = []
++ const alive = (side: Unit[]) => side.filter((u) => u.hp > 0)
++ const foesOf = (u: Unit) => (u.side === 0 ? d : a)
++ const friendsOf = (u: Unit) => (u.side === 0 ? a : d)
++ const front = (side: Unit[]): Unit | undefined =>
++ alive(side).sort((x, y) => FRONT[x.cls] - FRONT[y.cls] || x.pos - y.pos)[0]
++ const weakest = (side: Unit[]): Unit | undefined =>
++ alive(side).sort((x, y) => x.hp / x.maxHp - y.hp / y.maxHp || x.pos - y.pos)[0]
++
++ const strike = (from: Unit, to: Unit, mult: number): Hit => {
++ const adv = advantage(from.element, to.element)
++ const crit = rng.next() < 0.1
++ const spread = 0.95 + rng.next() * 0.1
++ const atk = from.atk * (1 + (from.rallyRounds > 0 ? from.rally : 0))
++ let dmg = Math.max(
++ 1,
++ // Attack against defence as a ratio, so damage keeps pace at every level.
++ Math.round(atk * mult * (atk / (atk + to.def * 1.2)) * adv * (crit ? 1.6 : 1) * spread),
++ )
++ const blocked = Math.min(to.shield, dmg)
++ to.shield -= blocked
++ dmg = Math.min(to.hp, dmg - blocked)
++ to.hp -= dmg
++ const hit: Hit = { r: to.r, dmg }
++ if (blocked > 0) hit.blocked = blocked
++ to.energy = Math.min(100, to.energy + 10)
++ if (crit) hit.crit = 1
++ if (adv > 1) hit.adv = 1
++ else if (adv < 1) hit.adv = -1
++ if (to.hp === 0) hit.ko = 1
++ return hit
++ }
++
++ let round = 0
++ while (round < maxRounds && alive(a).length && alive(d).length) {
++ round++
++ const order = alive(all).sort((x, y) => y.spd - x.spd || x.side - y.side || x.pos - y.pos)
++ for (const u of order) {
++ if (u.hp <= 0) continue
++ const foes = foesOf(u)
++ if (!alive(foes).length) break
++ const friends = friendsOf(u)
++ let ev: BattleEvent
++ if (u.energy >= 100) {
++ u.energy = 0
++ const kind = SKILL_OF[u.cls]
++ ev = { round, actor: u.r, kind, hits: [] }
++ if (kind === 'strike') {
++ const t = front(foes)
++ if (t) ev.hits.push(strike(u, t, u.power))
++ } else if (kind === 'snipe') {
++ const t = weakest(foes)
++ if (t) ev.hits.push(strike(u, t, u.power))
++ } else if (kind === 'nova') {
++ for (const t of alive(foes)) ev.hits.push(strike(u, t, u.power))
++ } else if (kind === 'heal') {
++ const amount = Math.round(u.atk * u.power)
++ const low = weakest(friends)
++ for (const t of alive(friends)) {
++ const heal = Math.min(t.maxHp - t.hp, t === low ? amount * 2 : amount)
++ t.hp += heal
++ ev.hits.push({ r: t.r, heal })
++ }
++ } else if (kind === 'shield') {
++ const amount = Math.round(u.maxHp * u.power)
++ for (const t of alive(friends)) {
++ t.shield += amount
++ ev.hits.push({ r: t.r, shield: amount })
++ }
++ } else {
++ for (const t of alive(friends)) {
++ t.rally = Math.max(t.rally, u.power)
++ t.rallyRounds = 2
++ if (t !== u) t.energy = Math.min(100, t.energy + 20)
++ ev.hits.push({ r: t.r })
++ }
++ }
++ } else {
++ u.energy = Math.min(100, u.energy + 30)
++ const t =
++ u.cls === 'ranger'
++ ? weakest(foes)
++ : u.cls === 'arcanist'
++ ? alive(foes)[Math.floor(rng.next() * alive(foes).length)]
++ : front(foes)
++ ev = { round, actor: u.r, kind: 'attack', hits: t ? [strike(u, t, 1)] : [] }
++ }
++ events.push(ev)
++ }
++ for (const u of all) if (u.rallyRounds > 0) u.rallyRounds--
++ }
++ const won = alive(a).length > 0 && alive(d).length === 0
++ return {
++ units: [
++ ...attackers.map((s, i) => ({ r: `a${i}`, id: s.id, hp: s.stats.hp })),
++ ...defenders.map((s, i) => ({
++ r: `d${i}`,
++ id: s.id,
++ hp: s.stats.hp,
++ ...(s.boss ? { boss: 1 as const } : {}),
++ })),
++ ],
++ events,
++ won,
++ rounds: round,
++ lost: a.filter((u) => u.hp === 0).length,
++ }
++}
++
++/** Three stars with no one knocked out, two with one, otherwise one. */
++export function starsFor(log: BattleLog): number {
++ if (!log.won) return 0
++ return log.lost === 0 ? 3 : log.lost === 1 ? 2 : 1
++}
ddiff --git a/packages/casual/src/casual.test.ts b/packages/casual/src/casual.test.ts
nnew file mode 100644
iindex 0000000..80d2362
---- /dev/null
++++ b/packages/casual/src/casual.test.ts
@@@ -0,0 +1,387 @@
++import { describe, expect, it } from 'vitest'
++import { createRng } from '@openvibe/shared'
++import {
++ apply,
++ BASE_STARS,
++ DEFAULT_POWER,
++ drawOdds,
++ energyNow,
++ enemyStats,
++ featuredFor,
++ heroDef,
++ heroStats,
++ idlePending,
++ newPlayer,
++ notices,
++ packDigest,
++ parseAction,
++ productReward,
++ questProgress,
++ Refusal,
++ simulate,
++ validatePack,
++ vibeHeroes as pack,
++ type Action,
++ type Outcome,
++ type PlayerState,
++ type UnitSpec,
++} from './index.js'
++
++const T0 = Date.UTC(2026, 9, 14, 12, 0, 0) // a Wednesday
++const MIN = 60_000
++
++function play(state: PlayerState, action: Action, now = T0, seed = 1): Outcome {
++ return apply(pack, state, action, now, createRng(seed))
++}
++
++function refused(fn: () => unknown): string {
++ try {
++ fn()
++ } catch (err) {
++ if (err instanceof Refusal) return err.code
++ throw err
++ }
++ throw new Error('expected a refusal')
++}
++
++/** The ledger lines of an outcome add up to the change in each wallet currency. */
++function ledgerMatches(before: PlayerState, out: Outcome): void {
++ for (const c of ['gold', 'gems', 'gemsPaid', 'sigils'] as const) {
++ const sum = out.deltas.filter((d) => d.asset === c).reduce((a, d) => a + d.delta, 0)
++ expect(out.state.wallet[c] - before.wallet[c]).toBe(sum)
++ }
++}
++
++describe('Vibe Heroes pack', () => {
++ it('is valid: 20 heroes, 100 stages, odds adding up to one', () => {
++ expect(validatePack(pack)).toEqual([])
++ expect(pack.heroes).toHaveLength(20)
++ expect(pack.stages).toHaveLength(100)
++ const total = drawOdds(pack, T0).reduce((a, o) => a + o.chance, 0)
++ expect(total).toBeCloseTo(1, 9)
++ })
++
++ it('has a stable digest that changes with the rules', () => {
++ expect(packDigest(pack)).toMatch(/^[0-9a-f]{8}$/)
++ expect(packDigest({ ...pack, teamSize: 4 })).not.toBe(packDigest(pack))
++ })
++
++ it('never sells sigils: summons are earned only', () => {
++ for (const p of pack.store) {
++ expect(p.reward.sigils ?? 0).toBe(0)
++ }
++ })
++})
++
++describe('a new player', () => {
++ it('starts with the pack’s heroes in the team, its wallet and full energy', () => {
++ const { state, deltas } = newPlayer(pack, T0)
++ expect(state.team).toEqual(['gus', 'tess', 'milo', 'rook'])
++ expect(state.wallet).toEqual({ gold: 5000, gems: 100, gemsPaid: 0, sigils: 10 })
++ expect(energyNow(pack, state, T0)).toBe(pack.energy.max)
++ expect(deltas.filter((d) => d.asset.startsWith('hero:'))).toHaveLength(4)
++ })
++})
++
++describe('battles', () => {
++ it('clears stage 1 for free and pays the first-clear reward once', () => {
++ const start = newPlayer(pack, T0).state
++ const out = play(start, { t: 'battle', stage: 1 })
++ expect(out.result.won).toBe(true)
++ expect(out.state.stage.cleared).toBe(1)
++ expect(out.result.energy).toBe(0)
++ expect(out.state.wallet.gold).toBe(5000 + 350)
++ ledgerMatches(start, out)
++ // The same stage again is a repeat: it costs energy and pays the repeat reward.
++ const again = play(out.state, { t: 'battle', stage: 1 }, T0 + 1000)
++ expect(again.result.first).toBe(false)
++ expect(energyNow(pack, again.state, T0 + 1000)).toBe(pack.energy.max - 6)
++ expect(again.state.wallet.gold - out.state.wallet.gold).toBe(110)
++ })
++
++ it('refuses a stage beyond the next one', () => {
++ const start = newPlayer(pack, T0).state
++ expect(refused(() => play(start, { t: 'battle', stage: 3 }))).toBe('locked_stage')
++ })
++
++ it('plays out the same log for the same seed', () => {
++ const start = newPlayer(pack, T0).state
++ const a = play(start, { t: 'battle', stage: 1 }, T0, 42).result.battle
++ const b = play(start, { t: 'battle', stage: 1 }, T0, 42).result.battle
++ expect(a).toEqual(b)
++ })
++
++ it('returns all but one energy when a battle is lost', () => {
++ const s = newPlayer(pack, T0).state
++ s.stage.cleared = 40
++ s.team = ['gus']
++ const out = play(s, { t: 'battle', stage: 41 })
++ expect(out.result.won).toBe(false)
++ expect(energyNow(pack, out.state, T0)).toBe(pack.energy.max - 1)
++ })
++
++ it('a first-session team (one epic, one rare) beats the first boss at level 9', () => {
++ const team: UnitSpec[] = ['gus', 'tess', 'hana', 'milo', 'juno'].map((id) => {
++ const d = heroDef(pack, id)!
++ return {
++ id,
++ cls: d.class,
++ element: d.element,
++ stats: heroStats(pack, d, { level: 9, stars: BASE_STARS[d.rarity], gear: {} }),
++ power: d.skill.power,
++ }
++ })
++ const stage = pack.stages[9]!
++ const foes: UnitSpec[] = stage.enemies.map((eid) => {
++ const e = pack.enemies.find((x) => x.id === eid)!
++ return {
++ id: e.id,
++ cls: e.class,
++ element: e.element,
++ stats: enemyStats(e.class, stage.level, stage.index, e.boss),
++ power: DEFAULT_POWER[e.class],
++ boss: e.boss,
++ }
++ })
++ let wins = 0
++ for (let i = 0; i < 40; i++) if (simulate(team, foes, createRng(i)).won) wins++
++ expect(wins / 40).toBeGreaterThanOrEqual(0.6)
++ })
++
++ it('sweeps only three-star stages, for energy', () => {
++ let s = newPlayer(pack, T0).state
++ s.stage.cleared = 12
++ s.stage.stars = { '12': 2, '11': 3 }
++ expect(refused(() => play(s, { t: 'sweep', stage: 12, times: 1 }))).toBe('needs_three_stars')
++ const out = play(s, { t: 'sweep', stage: 11, times: 3 })
++ expect(energyNow(pack, out.state, T0)).toBe(pack.energy.max - 18)
++ expect(out.state.shards[pack.stages[10]!.repeat.shards![0]!.hero]).toBe(3)
++ s = out.state
++ expect(refused(() => play(s, { t: 'sweep', stage: 11, times: 10 }))).toBe('not_enough_energy')
++ })
++})
++
++describe('energy and idle', () => {
++ it('refills one point every five minutes up to the cap', () => {
++ const s = newPlayer(pack, T0).state
++ s.stage.cleared = 12
++ const out = play(s, { t: 'battle', stage: 12 })
++ const spent = Number(out.result.energy)
++ expect(spent).toBeGreaterThan(0)
++ expect(energyNow(pack, out.state, T0 + 5 * MIN)).toBe(pack.energy.max - spent + 1)
++ expect(energyNow(pack, out.state, T0 + 600 * MIN)).toBe(pack.energy.max)
++ })
++
++ it('keeps an energy refill above the cap until it is spent', () => {
++ const s = newPlayer(pack, T0).state
++ s.wallet.gems = 500
++ const out = play(s, { t: 'buy', product: 'energy_refill' })
++ expect(energyNow(pack, out.state, T0 + 60 * MIN)).toBe(pack.energy.max + 60)
++ })
++
++ it('fills the idle chest by the minute and stops at twelve hours', () => {
++ const s = newPlayer(pack, T0).state
++ expect(refused(() => play(s, { t: 'collect' }, T0 + 30_000))).toBe('not_ready')
++ const p = idlePending(pack, s, T0 + 20 * 3_600_000)
++ expect(p.minutes).toBe(12 * 60)
++ expect(p.gems).toBe(12)
++ expect(p.full).toBe(true)
++ const out = play(s, { t: 'collect' }, T0 + 20 * 3_600_000)
++ expect(out.state.wallet.gold - s.wallet.gold).toBe(12 * 60 * 15)
++ expect(out.state.idle.at).toBe(T0 + 20 * 3_600_000)
++ ledgerMatches(s, out)
++ })
++})
++
++describe('heroes', () => {
++ it('levels up as far as the gold goes, never past the cap', () => {
++ const s = newPlayer(pack, T0).state
++ const out = play(s, { t: 'level', hero: 'tess', times: 200 })
++ expect(out.state.heroes.tess!.level).toBeLessThanOrEqual(20)
++ expect(Number(out.result.levels)).toBeGreaterThan(5)
++ ledgerMatches(s, out)
++ const rich = structuredClone(s)
++ rich.wallet.gold = 10_000_000
++ const capped = play(rich, { t: 'level', hero: 'tess', times: 200 })
++ expect(capped.state.heroes.tess!.level).toBe(20)
++ expect(refused(() => play(capped.state, { t: 'level', hero: 'tess', times: 1 }))).toBe(
++ 'max_level',
++ )
++ expect(refused(() => play(s, { t: 'level', hero: 'nyx', times: 1 }))).toBe('not_owned')
++ })
++
++ it('fuses Hana from the third stage’s shards and puts her in the team', () => {
++ let s = newPlayer(pack, T0).state
++ for (const stage of [1, 2, 3]) {
++ s.team = ['gus', 'tess', 'milo', 'rook']
++ s = play(s, { t: 'battle', stage }, T0, 9).state
++ }
++ expect(s.stage.cleared).toBe(3)
++ expect(s.shards.hana).toBeGreaterThanOrEqual(20)
++ const out = play(s, { t: 'fuse', hero: 'hana' })
++ expect(out.state.heroes.hana).toEqual({ level: 1, stars: 2, gear: {} })
++ expect(out.state.team).toContain('hana')
++ expect(refused(() => play(out.state, { t: 'fuse', hero: 'hana' }))).toBe('already_owned')
++ })
++
++ it('stars up for shards and gold, raising the level cap', () => {
++ const s = newPlayer(pack, T0).state
++ s.shards.tess = 15
++ const out = play(s, { t: 'star', hero: 'tess' })
++ expect(out.state.heroes.tess!.stars).toBe(2)
++ expect(out.state.shards.tess).toBe(5)
++ expect(out.state.wallet.gold).toBe(3000)
++ expect(
++ questProgress(
++ out.state,
++ pack.quests.find((q) => q.id === 'w_star1')!,
++ T0,
++ ).n,
++ ).toBe(1)
++ })
++
++ it('equips gear into its own slot and returns what it replaces', () => {
++ const s = newPlayer(pack, T0).state
++ s.gear = { rustwood_axe: 1, ember_blade: 1 }
++ const one = play(s, { t: 'equip', hero: 'tess', slot: 'weapon', gear: 'rustwood_axe' }).state
++ expect(one.heroes.tess!.gear.weapon).toBe('rustwood_axe')
++ expect(one.gear.rustwood_axe).toBeUndefined()
++ const two = play(one, { t: 'equip', hero: 'tess', slot: 'weapon', gear: 'ember_blade' }).state
++ expect(two.gear).toEqual({ rustwood_axe: 1 })
++ expect(
++ refused(() => play(two, { t: 'equip', hero: 'tess', slot: 'armor', gear: 'rustwood_axe' })),
++ ).toBe('bad_gear')
++ const off = play(two, { t: 'equip', hero: 'tess', slot: 'weapon', gear: null }).state
++ expect(off.gear).toEqual({ rustwood_axe: 1, ember_blade: 1 })
++ })
++})
++
++describe('summons', () => {
++ it('ten draws cost ten sigils and always hold an epic or better', () => {
++ const s = newPlayer(pack, T0).state
++ for (let seed = 1; seed <= 30; seed++) {
++ const out = play(s, { t: 'summon', count: 10 }, T0, seed)
++ const pulls = out.result.pulls as { rarity: string }[]
++ expect(pulls).toHaveLength(10)
++ expect(pulls.some((p) => p.rarity === 'epic' || p.rarity === 'legendary')).toBe(true)
++ expect(out.state.wallet.sigils).toBe(0)
++ ledgerMatches(s, out)
++ }
++ })
++
++ it('makes the draw at the pity count a legendary and resets the counter', () => {
++ const s = newPlayer(pack, T0).state
++ s.summon.pity = pack.banner.pity - 1
++ const out = play(s, { t: 'summon', count: 1 }, T0, 5)
++ const pulls = out.result.pulls as { rarity: string }[]
++ expect(pulls[0]!.rarity).toBe('legendary')
++ expect(out.state.summon.pity).toBe(0)
++ })
++
++ it('turns a duplicate into shards', () => {
++ const s = newPlayer(pack, T0).state
++ for (const h of pack.heroes) s.heroes[h.id] ??= { level: 1, stars: 1, gear: {} }
++ const out = play(s, { t: 'summon', count: 1 }, T0, 3)
++ const pull = (out.result.pulls as { hero: string; new: boolean; shards: number }[])[0]!
++ expect(pull.new).toBe(false)
++ expect(out.state.shards[pull.hero]).toBe(pull.shards)
++ })
++
++ it('refuses without sigils', () => {
++ const s = newPlayer(pack, T0).state
++ s.wallet.sigils = 0
++ expect(refused(() => play(s, { t: 'summon', count: 1 }))).toBe('not_enough_sigils')
++ })
++})
++
++describe('quests, logins and the store', () => {
++ it('claims a daily quest once, and a new day starts it over', () => {
++ let s = newPlayer(pack, T0).state
++ for (const stage of [1, 2, 3]) s = play(s, { t: 'battle', stage }, T0, 9).state
++ const win3 = pack.quests.find((q) => q.id === 'd_win3')!
++ expect(questProgress(s, win3, T0).n).toBe(3)
++ const out = play(s, { t: 'quest', quest: 'd_win3' })
++ expect(refused(() => play(out.state, { t: 'quest', quest: 'd_win3' }))).toBe('already_claimed')
++ const tomorrow = T0 + 24 * 3_600_000
++ expect(questProgress(out.state, win3, tomorrow)).toEqual({ n: 0, claimed: false })
++ // The week is the same, so weekly progress stays.
++ const w = pack.quests.find((q) => q.id === 'w_win30')!
++ expect(questProgress(out.state, w, tomorrow).n).toBe(3)
++ expect(refused(() => play(out.state, { t: 'quest', quest: 'd_win3' }, tomorrow))).toBe(
++ 'not_ready',
++ )
++ })
++
++ it('pays one login reward a day, moving through the calendar', () => {
++ const s = newPlayer(pack, T0).state
++ const one = play(s, { t: 'login' })
++ expect(one.state.wallet.gold - s.wallet.gold).toBe(3000)
++ expect(refused(() => play(one.state, { t: 'login' }, T0 + 1000))).toBe('already_claimed')
++ const two = play(one.state, { t: 'login' }, T0 + 24 * 3_600_000)
++ expect(two.state.wallet.gems - one.state.wallet.gems).toBe(30)
++ expect(notices(pack, two.state, T0 + 24 * 3_600_000).login).toBe(false)
++ })
++
++ it('spends earned gems before bought ones and keeps to the daily limit', () => {
++ const s = newPlayer(pack, T0).state
++ s.wallet.gems = 30
++ s.wallet.gemsPaid = 200
++ const one = play(s, { t: 'buy', product: 'energy_refill' })
++ expect(one.state.wallet.gems).toBe(0)
++ expect(one.state.wallet.gemsPaid).toBe(180)
++ ledgerMatches(s, one)
++ const two = play(one.state, { t: 'buy', product: 'energy_refill' }).state
++ const three = play(two, { t: 'buy', product: 'energy_refill' }).state
++ expect(refused(() => play(three, { t: 'buy', product: 'energy_refill' }))).toBe('limit_reached')
++ expect(
++ play(three, { t: 'buy', product: 'energy_refill' }, T0 + 24 * 3_600_000).state.wallet
++ .gemsPaid,
++ ).toBe(30)
++ })
++
++ it('sells this week’s featured shards, listed before buying', () => {
++ const s = newPlayer(pack, T0).state
++ s.wallet.gems = 1000
++ const f = featuredFor(pack, T0)
++ expect(productReward(pack, s, 'featured_legendary', T0)).toEqual({
++ shards: [{ hero: f.legendary, n: 30 }],
++ })
++ const out = play(s, { t: 'buy', product: 'featured_legendary' })
++ expect(out.state.shards[f.legendary]).toBe(30)
++ expect(refused(() => play(out.state, { t: 'buy', product: 'featured_legendary' }))).toBe(
++ 'limit_reached',
++ )
++ })
++
++ it('prices the gold crate as idle gold at the player’s stage', () => {
++ const s = newPlayer(pack, T0).state
++ s.stage.cleared = 20
++ expect(productReward(pack, s, 'gold_crate', T0)).toEqual({ gold: 120 * (15 + 3 * 20) })
++ })
++})
++
++describe('parseAction', () => {
++ it('accepts exactly the known shapes', () => {
++ expect(parseAction({ t: 'battle', stage: 3 })).toEqual({ t: 'battle', stage: 3 })
++ expect(parseAction({ t: 'summon', count: 10 })).toEqual({ t: 'summon', count: 10 })
++ expect(parseAction({ t: 'equip', hero: 'tess', slot: 'weapon', gear: null })).toEqual({
++ t: 'equip',
++ hero: 'tess',
++ slot: 'weapon',
++ gear: null,
++ })
++ for (const bad of [
++ null,
++ 'battle',
++ { t: 'battle', stage: 0 },
++ { t: 'battle', stage: 1.5 },
++ { t: 'summon', count: 5 },
++ { t: 'level', hero: 'Tess', times: 1 },
++ { t: 'team', heroes: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] },
++ { t: 'equip', hero: 'tess', slot: 'hat', gear: 'x' },
++ { t: 'grant', gold: 1e9 },
++ ]) {
++ expect(parseAction(bad)).toBeNull()
++ }
++ })
++})
ddiff --git a/packages/casual/src/content/vibeHeroes.ts b/packages/casual/src/content/vibeHeroes.ts
nnew file mode 100644
iindex 0000000..bb1e199
---- /dev/null
++++ b/packages/casual/src/content/vibeHeroes.ts
@@@ -0,0 +1,846 @@
++/**
++ * Vibe Heroes, the casual kit's first game (plan T22): an idle hero collector. Every character here is original
++ * and an adult. Portraits are drawn by the client from `look`.
++ */
++import type {
++ CasualPack,
++ ChapterDef,
++ EnemyDef,
++ GearDef,
++ HeroDef,
++ ProductDef,
++ QuestDef,
++ Reward,
++ StageDef,
++} from '../types.js'
++
++const heroes: HeroDef[] = [
++ // Legendary
++ {
++ id: 'seraphine',
++ name: 'Seraphine Vale',
++ title: 'the Dawnblade',
++ rarity: 'legendary',
++ element: 'dawn',
++ class: 'blade',
++ skill: { name: 'Sunbreak', text: 'Cuts the front enemy for 320% attack.', power: 3.2 },
++ bio: 'Knight-captain of an order that forgot its oath. She did not, and walked out of the first sunrise after the Prism broke.',
++ look: {
++ skin: '#f3d2b8',
++ hair: '#f4e3a1',
++ hairStyle: 1,
++ eyes: '#d4a017',
++ outfit: '#f7f3e8',
++ trim: '#e0a526',
++ accessory: 3,
++ },
++ },
++ {
++ id: 'kaito',
++ name: 'Kaito Ashveil',
++ title: 'Ember Sovereign',
++ rarity: 'legendary',
++ element: 'flame',
++ class: 'arcanist',
++ skill: { name: 'Crownfire', text: 'Burns every enemy for 145% attack.', power: 1.45 },
++ bio: 'Gave up a furnace-city throne to study the fire itself. Still dresses like a king, out of habit.',
++ look: {
++ skin: '#e8b896',
++ hair: '#2b1a17',
++ hairStyle: 4,
++ eyes: '#ff7a1a',
++ outfit: '#7a1f1f',
++ trim: '#ffb347',
++ accessory: 1,
++ },
++ },
++ {
++ id: 'nyx',
++ name: 'Nyx Morrow',
++ title: 'Night Archivist',
++ rarity: 'legendary',
++ element: 'dusk',
++ class: 'ranger',
++ skill: { name: 'Last Page', text: 'Shoots the weakest enemy for 380% attack.', power: 3.8 },
++ bio: 'Keeps the archive of everything the dark has eaten. Reads by starlight and shoots by ear.',
++ look: {
++ skin: '#c98f6b',
++ hair: '#3b2a5a',
++ hairStyle: 2,
++ eyes: '#b48cff',
++ outfit: '#1d1b33',
++ trim: '#8e6cff',
++ accessory: 4,
++ },
++ },
++ {
++ id: 'thalia',
++ name: 'Thalia Reed',
++ title: 'Tidecaller',
++ rarity: 'legendary',
++ element: 'tide',
++ class: 'mender',
++ skill: {
++ name: 'High Tide',
++ text: 'Heals every ally for 135% attack, double for the most hurt.',
++ power: 1.35,
++ },
++ bio: 'A lighthouse keeper who learned the sea answers when asked politely, and twice as fast when asked rudely.',
++ look: {
++ skin: '#7a4a2e',
++ hair: '#1f6f8b',
++ hairStyle: 5,
++ eyes: '#5fd3e8',
++ outfit: '#0f4c63',
++ trim: '#9be7f5',
++ accessory: 2,
++ },
++ },
++ // Epic
++ {
++ id: 'bram',
++ name: 'Bram Ironbark',
++ title: 'Old Oak',
++ rarity: 'epic',
++ element: 'grove',
++ class: 'guardian',
++ skill: { name: 'Rootwall', text: 'Shields every ally for 16% of his max HP.', power: 0.16 },
++ bio: 'Retired forester with a shield cut from a lightning-struck oak. Has never once taken a step back.',
++ look: {
++ skin: '#a86f4c',
++ hair: '#6b4a2b',
++ hairStyle: 6,
++ eyes: '#6abf4b',
++ outfit: '#3f5a2a',
++ trim: '#a3c76d',
++ accessory: 5,
++ },
++ },
++ {
++ id: 'juno',
++ name: 'Juno Spark',
++ title: 'Fuse Runner',
++ rarity: 'epic',
++ element: 'flame',
++ class: 'ranger',
++ skill: { name: 'Hot Shot', text: 'Shoots the weakest enemy for 330% attack.', power: 3.3 },
++ bio: 'Demolition courier. Delivers on time, usually with the building still standing.',
++ look: {
++ skin: '#f1c7a1',
++ hair: '#e8432b',
++ hairStyle: 3,
++ eyes: '#ffb347',
++ outfit: '#3a3a44',
++ trim: '#ff6b3d',
++ accessory: 6,
++ },
++ },
++ {
++ id: 'mira',
++ name: 'Mira Quell',
++ title: 'Glass Sea Sage',
++ rarity: 'epic',
++ element: 'tide',
++ class: 'arcanist',
++ skill: { name: 'Undertow', text: 'Drags every enemy under for 125% attack.', power: 1.25 },
++ bio: 'Professor of tides at a floating university. Grades harshly and casts harder.',
++ look: {
++ skin: '#e8b896',
++ hair: '#9fd8e8',
++ hairStyle: 0,
++ eyes: '#2a8fbd',
++ outfit: '#1b3f73',
++ trim: '#7fd1ff',
++ accessory: 1,
++ },
++ },
++ {
++ id: 'ozric',
++ name: 'Ozric Thorn',
++ title: 'Thornsworn',
++ rarity: 'epic',
++ element: 'dusk',
++ class: 'blade',
++ skill: { name: 'Bramble Cut', text: 'Cuts the front enemy for 290% attack.', power: 2.9 },
++ bio: 'A duelist who swore to the thorn queen and outlived her court. Polite, patient and very sharp.',
++ look: {
++ skin: '#c98f6b',
++ hair: '#1a1a1a',
++ hairStyle: 7,
++ eyes: '#c04080',
++ outfit: '#2a1f2f',
++ trim: '#7d3c98',
++ accessory: 0,
++ },
++ },
++ {
++ id: 'lumen',
++ name: 'Lumen Ash',
++ title: 'Lanternkeeper',
++ rarity: 'epic',
++ element: 'dawn',
++ class: 'herald',
++ skill: {
++ name: 'Kindle',
++ text: 'Raises every ally’s attack by 38% for two rounds and charges them.',
++ power: 0.38,
++ },
++ bio: 'Lights the road for caravans. Talks to every traveller and remembers all their names.',
++ look: {
++ skin: '#f3d2b8',
++ hair: '#c9c9c9',
++ hairStyle: 2,
++ eyes: '#ffd166',
++ outfit: '#5b4a2e',
++ trim: '#ffd166',
++ accessory: 2,
++ },
++ },
++ {
++ id: 'sable',
++ name: 'Sable Wren',
++ title: 'Hex Cartographer',
++ rarity: 'epic',
++ element: 'dusk',
++ class: 'arcanist',
++ skill: { name: 'Ink Storm', text: 'Floods every enemy with ink for 120% attack.', power: 1.2 },
++ bio: 'Maps places that move. Her ink knows the way even when she does not.',
++ look: {
++ skin: '#7a4a2e',
++ hair: '#2d2d44',
++ hairStyle: 1,
++ eyes: '#9b8cff',
++ outfit: '#2b2440',
++ trim: '#c7b8ff',
++ accessory: 4,
++ },
++ },
++ // Rare
++ {
++ id: 'pip',
++ name: 'Pip Copperfield',
++ title: 'Gearwright',
++ rarity: 'rare',
++ element: 'grove',
++ class: 'herald',
++ skill: {
++ name: 'Overclock',
++ text: 'Raises every ally’s attack by 32% for two rounds and charges them.',
++ power: 0.32,
++ },
++ bio: 'A tinker who fixes anything with a spring in it, including morale.',
++ look: {
++ skin: '#f1c7a1',
++ hair: '#b5651d',
++ hairStyle: 3,
++ eyes: '#4caf50',
++ outfit: '#6d4c2f',
++ trim: '#d9a441',
++ accessory: 6,
++ },
++ },
++ {
++ id: 'rosa',
++ name: 'Rosa Flint',
++ title: 'Forge Warden',
++ rarity: 'rare',
++ element: 'flame',
++ class: 'guardian',
++ skill: { name: 'Anvil Guard', text: 'Shields every ally for 13% of her max HP.', power: 0.13 },
++ bio: 'Runs the city forge and the city watch on alternate days. Nobody has noticed the difference.',
++ look: {
++ skin: '#a86f4c',
++ hair: '#3d1f12',
++ hairStyle: 6,
++ eyes: '#ff8c42',
++ outfit: '#5a2e1f',
++ trim: '#e07a3a',
++ accessory: 5,
++ },
++ },
++ {
++ id: 'dex',
++ name: 'Dex Harbor',
++ title: 'Dockside Duelist',
++ rarity: 'rare',
++ element: 'tide',
++ class: 'blade',
++ skill: { name: 'Riptide', text: 'Cuts the front enemy for 270% attack.', power: 2.7 },
++ bio: 'Settles harbour disputes with a rapier and a grin. Owes everyone money.',
++ look: {
++ skin: '#e8b896',
++ hair: '#1d3557',
++ hairStyle: 4,
++ eyes: '#48cae4',
++ outfit: '#264653',
++ trim: '#a8dadc',
++ accessory: 0,
++ },
++ },
++ {
++ id: 'ivy',
++ name: 'Ivy Lark',
++ title: 'Greenhouse Medic',
++ rarity: 'rare',
++ element: 'grove',
++ class: 'mender',
++ skill: {
++ name: 'Bloom',
++ text: 'Heals every ally for 110% attack, double for the most hurt.',
++ power: 1.1,
++ },
++ bio: 'Grows her own medicine and names every plant. The plants seem to appreciate it.',
++ look: {
++ skin: '#f3d2b8',
++ hair: '#5c8a3a',
++ hairStyle: 5,
++ eyes: '#8bc34a',
++ outfit: '#e8f5e0',
++ trim: '#5c8a3a',
++ accessory: 2,
++ },
++ },
++ {
++ id: 'corin',
++ name: 'Corin Vey',
++ title: 'Lightwall',
++ rarity: 'rare',
++ element: 'dawn',
++ class: 'guardian',
++ skill: { name: 'Halo', text: 'Shields every ally for 13% of his max HP.', power: 0.13 },
++ bio: 'Temple guard turned bodyguard-for-hire. Charges extra for heroics, does them anyway.',
++ look: {
++ skin: '#c98f6b',
++ hair: '#e9c46a',
++ hairStyle: 7,
++ eyes: '#f4a261',
++ outfit: '#d8d2c2',
++ trim: '#e9c46a',
++ accessory: 3,
++ },
++ },
++ {
++ id: 'hana',
++ name: 'Hana Ito',
++ title: 'Harbor Archer',
++ rarity: 'rare',
++ element: 'tide',
++ class: 'ranger',
++ skill: { name: 'Gull Shot', text: 'Shoots the weakest enemy for 310% attack.', power: 3.1 },
++ bio: 'Fishing-fleet lookout who can hit a gull at four hundred paces. She chooses not to.',
++ look: {
++ skin: '#f1c7a1',
++ hair: '#111827',
++ hairStyle: 0,
++ eyes: '#38bdf8',
++ outfit: '#0e7490',
++ trim: '#67e8f9',
++ accessory: 4,
++ },
++ },
++ // Common
++ {
++ id: 'milo',
++ name: 'Milo Bright',
++ title: 'Field Medic',
++ rarity: 'common',
++ element: 'dawn',
++ class: 'mender',
++ skill: {
++ name: 'Patch Up',
++ text: 'Heals every ally for 100% attack, double for the most hurt.',
++ power: 1,
++ },
++ bio: 'Village doctor who followed the heroes out of the gate because someone had to.',
++ look: {
++ skin: '#e8b896',
++ hair: '#8d5524',
++ hairStyle: 2,
++ eyes: '#6d4c41',
++ outfit: '#f0ece2',
++ trim: '#d64545',
++ accessory: 0,
++ },
++ },
++ {
++ id: 'tess',
++ name: 'Tess Marlow',
++ title: 'Sellsword',
++ rarity: 'common',
++ element: 'grove',
++ class: 'blade',
++ skill: { name: 'Cleave', text: 'Cuts the front enemy for 260% attack.', power: 2.6 },
++ bio: 'Works for coin, fights for friends, and has lately been doing a lot of the second.',
++ look: {
++ skin: '#c98f6b',
++ hair: '#4a2c1a',
++ hairStyle: 3,
++ eyes: '#5d8a3a',
++ outfit: '#4b5d3a',
++ trim: '#a0b46a',
++ accessory: 0,
++ },
++ },
++ {
++ id: 'gus',
++ name: 'Gus Barrow',
++ title: 'Gatekeeper',
++ rarity: 'common',
++ element: 'flame',
++ class: 'guardian',
++ skill: { name: 'Brace', text: 'Shields every ally for 12% of his max HP.', power: 0.12 },
++ bio: 'Kept the town gate for thirty years. Brought the gate with him.',
++ look: {
++ skin: '#7a4a2e',
++ hair: '#9e9e9e',
++ hairStyle: 6,
++ eyes: '#8d6e63',
++ outfit: '#6d4c41',
++ trim: '#c58b4a',
++ accessory: 5,
++ },
++ },
++ {
++ id: 'rook',
++ name: 'Rook',
++ title: 'Courier',
++ rarity: 'common',
++ element: 'dusk',
++ class: 'herald',
++ skill: {
++ name: 'Tailwind',
++ text: 'Raises every ally’s attack by 30% for two rounds and charges them.',
++ power: 0.3,
++ },
++ bio: 'Rides between towns with a crow on each shoulder and news for everyone.',
++ look: {
++ skin: '#f3d2b8',
++ hair: '#263238',
++ hairStyle: 4,
++ eyes: '#90a4ae',
++ outfit: '#37474f',
++ trim: '#b0bec5',
++ accessory: 6,
++ },
++ },
++]
++
++const minion = (
++ id: string,
++ name: string,
++ kind: EnemyDef['kind'],
++ element: EnemyDef['element'],
++ cls: EnemyDef['class'],
++): EnemyDef => ({ id, name, kind, element, class: cls, boss: false })
++const boss = (
++ id: string,
++ name: string,
++ kind: EnemyDef['kind'],
++ element: EnemyDef['element'],
++ cls: EnemyDef['class'],
++): EnemyDef => ({ id, name, kind, element, class: cls, boss: true })
++
++const enemies: EnemyDef[] = [
++ minion('moss_slime', 'Moss Slime', 'slime', 'grove', 'guardian'),
++ minion('bog_slime', 'Bog Slime', 'slime', 'dusk', 'guardian'),
++ minion('glow_beetle', 'Glow Beetle', 'beetle', 'dawn', 'guardian'),
++ minion('shell_beetle', 'Shell Beetle', 'beetle', 'tide', 'guardian'),
++ minion('ember_imp', 'Ember Imp', 'imp', 'flame', 'blade'),
++ minion('thorn_imp', 'Thorn Imp', 'imp', 'grove', 'blade'),
++ minion('tide_wisp', 'Tide Wisp', 'wisp', 'tide', 'arcanist'),
++ minion('cinder_wisp', 'Cinder Wisp', 'wisp', 'flame', 'arcanist'),
++ minion('mirror_wisp', 'Mirror Wisp', 'wisp', 'dawn', 'mender'),
++ minion('rust_drone', 'Rust Drone', 'drone', 'flame', 'ranger'),
++ minion('sky_drone', 'Sky Drone', 'drone', 'dawn', 'ranger'),
++ minion('shade', 'Shade', 'specter', 'dusk', 'arcanist'),
++ minion('gloom', 'Gloom', 'specter', 'dusk', 'herald'),
++ minion('stone_golem', 'Stone Golem', 'golem', 'grove', 'guardian'),
++ minion('sand_golem', 'Sand Golem', 'golem', 'dawn', 'guardian'),
++ minion('storm_whelp', 'Storm Whelp', 'drake', 'tide', 'ranger'),
++ boss('king_slime', 'King Slime', 'slime', 'grove', 'guardian'),
++ boss('rust_colossus', 'Rust Colossus', 'golem', 'flame', 'guardian'),
++ boss('abyss_wisp', 'Abyss Wisp', 'wisp', 'tide', 'arcanist'),
++ boss('cinder_golem', 'Cinder Golem', 'golem', 'flame', 'blade'),
++ boss('moon_specter', 'Moon Specter', 'specter', 'dusk', 'arcanist'),
++ boss('clockwork_drake', 'Clockwork Drake', 'drake', 'dawn', 'ranger'),
++ boss('dune_drake', 'Dune Drake', 'drake', 'flame', 'blade'),
++ boss('thorn_golem', 'Thorn Golem', 'golem', 'grove', 'guardian'),
++ boss('rift_drake', 'Rift Drake', 'drake', 'dusk', 'arcanist'),
++ boss('prism_king', 'The Prism King', 'specter', 'dawn', 'arcanist'),
++]
++
++const chapters: ChapterDef[] = [
++ { index: 1, name: 'Glimmer Meadow', sky: ['#7dd3fc', '#bbf7d0'] },
++ { index: 2, name: 'Rustwood', sky: ['#fdba74', '#a3a35a'] },
++ { index: 3, name: 'Saltglass Coast', sky: ['#38bdf8', '#e0f2fe'] },
++ { index: 4, name: 'Ember Steps', sky: ['#f97316', '#7f1d1d'] },
++ { index: 5, name: 'Hollow Moon Marsh', sky: ['#4c1d95', '#1e293b'] },
++ { index: 6, name: 'Skyforge', sky: ['#fde68a', '#60a5fa'] },
++ { index: 7, name: 'Mirror Dunes', sky: ['#fcd34d', '#f9a8d4'] },
++ { index: 8, name: 'Thornspire', sky: ['#14532d', '#4a044e'] },
++ { index: 9, name: 'Starfall Rift', sky: ['#1e1b4b', '#7c3aed'] },
++ { index: 10, name: 'The Prism Throne', sky: ['#f0abfc', '#22d3ee'] },
++]
++
++/** Each chapter's minions, its boss, the heroes whose shards its stages give, and its boss's gear. */
++const chapterPlan: { minions: string[]; boss: string; shardHeroes: string[]; gear: string }[] = [
++ {
++ minions: ['moss_slime', 'glow_beetle', 'thorn_imp'],
++ boss: 'king_slime',
++ shardHeroes: ['hana', 'ivy'],
++ gear: 'meadow_charm',
++ },
++ {
++ minions: ['rust_drone', 'ember_imp', 'moss_slime'],
++ boss: 'rust_colossus',
++ shardHeroes: ['rosa', 'pip'],
++ gear: 'rustwood_axe',
++ },
++ {
++ minions: ['tide_wisp', 'shell_beetle', 'storm_whelp'],
++ boss: 'abyss_wisp',
++ shardHeroes: ['dex', 'corin'],
++ gear: 'saltglass_mail',
++ },
++ {
++ minions: ['ember_imp', 'cinder_wisp', 'rust_drone'],
++ boss: 'cinder_golem',
++ shardHeroes: ['juno', 'bram'],
++ gear: 'ember_blade',
++ },
++ {
++ minions: ['shade', 'bog_slime', 'gloom'],
++ boss: 'moon_specter',
++ shardHeroes: ['sable', 'ozric'],
++ gear: 'moonveil_cloak',
++ },
++ {
++ minions: ['sky_drone', 'glow_beetle', 'cinder_wisp'],
++ boss: 'clockwork_drake',
++ shardHeroes: ['lumen', 'mira'],
++ gear: 'skyforge_hammer',
++ },
++ {
++ minions: ['sand_golem', 'mirror_wisp', 'ember_imp'],
++ boss: 'dune_drake',
++ shardHeroes: ['kaito'],
++ gear: 'dune_amulet',
++ },
++ {
++ minions: ['thorn_imp', 'stone_golem', 'shade'],
++ boss: 'thorn_golem',
++ shardHeroes: ['thalia'],
++ gear: 'thorn_plate',
++ },
++ {
++ minions: ['gloom', 'storm_whelp', 'shade', 'tide_wisp'],
++ boss: 'rift_drake',
++ shardHeroes: ['nyx'],
++ gear: 'rift_lens',
++ },
++ {
++ minions: ['mirror_wisp', 'sky_drone', 'sand_golem', 'gloom'],
++ boss: 'prism_king',
++ shardHeroes: ['seraphine'],
++ gear: 'prism_edge',
++ },
++]
++
++const rarityOf = (id: string) => heroes.find((h) => h.id === id)?.rarity ?? 'common'
++
++function makeStages(): StageDef[] {
++ const out: StageDef[] = []
++ chapterPlan.forEach((plan, c) => {
++ for (let k = 1; k <= 10; k++) {
++ const s = c * 10 + k
++ // A boss stage is the boss and two of the chapter's minions.
++ const count = k === 10 ? 3 : s <= 2 ? 2 : s <= 5 ? 3 : s <= 20 ? 4 : 5
++ const list: string[] = []
++ if (k === 10) list.push(plan.boss)
++ while (list.length < count)
++ list.push(plan.minions[(s + list.length) % plan.minions.length] as string)
++ const shardHero = plan.shardHeroes[k % plan.shardHeroes.length] as string
++ const legendary = rarityOf(shardHero) === 'legendary'
++ const first: Reward = {
++ gold: 250 + 100 * s,
++ gems: k === 10 ? 60 : 15,
++ shards: [{ hero: shardHero, n: legendary ? 2 : 5 }],
++ }
++ if (k % 3 === 0) first.sigils = 1
++ if (k === 10) {
++ first.sigils = 5
++ first.gear = [plan.gear]
++ }
++ out.push({
++ index: s,
++ chapter: c + 1,
++ name: `${chapters[c]?.name ?? ''} ${k}`,
++ // The first stages stay at level 1 so the opening minutes are wins.
++ level: Math.max(1, Math.round((s - 1) * 1.12 - 1)),
++ enemies: list,
++ energy: 6,
++ first,
++ repeat: { gold: 80 + 30 * s, shards: [{ hero: shardHero, n: 1 }] },
++ })
++ }
++ })
++ // The first hook: the third stage hands over enough shards to fuse Hana straight away.
++ const third = out[2]
++ if (third) third.first.shards = [{ hero: 'hana', n: 20 }]
++ return out
++}
++
++const gear: GearDef[] = [
++ {
++ id: 'meadow_charm',
++ name: 'Meadow Charm',
++ slot: 'charm',
++ rarity: 'common',
++ bonus: { spd: 4, atk: 3 },
++ },
++ { id: 'rustwood_axe', name: 'Rustwood Axe', slot: 'weapon', rarity: 'common', bonus: { atk: 8 } },
++ {
++ id: 'saltglass_mail',
++ name: 'Saltglass Mail',
++ slot: 'armor',
++ rarity: 'rare',
++ bonus: { hp: 10, def: 8 },
++ },
++ { id: 'ember_blade', name: 'Ember Blade', slot: 'weapon', rarity: 'rare', bonus: { atk: 14 } },
++ {
++ id: 'moonveil_cloak',
++ name: 'Moonveil Cloak',
++ slot: 'armor',
++ rarity: 'rare',
++ bonus: { hp: 14, def: 10 },
++ },
++ {
++ id: 'skyforge_hammer',
++ name: 'Skyforge Hammer',
++ slot: 'weapon',
++ rarity: 'epic',
++ bonus: { atk: 20 },
++ },
++ {
++ id: 'dune_amulet',
++ name: 'Dune Amulet',
++ slot: 'charm',
++ rarity: 'epic',
++ bonus: { spd: 8, atk: 8 },
++ },
++ {
++ id: 'thorn_plate',
++ name: 'Thornplate',
++ slot: 'armor',
++ rarity: 'epic',
++ bonus: { hp: 22, def: 16 },
++ },
++ {
++ id: 'rift_lens',
++ name: 'Rift Lens',
++ slot: 'charm',
++ rarity: 'legendary',
++ bonus: { spd: 12, atk: 12 },
++ },
++ { id: 'prism_edge', name: 'Prism Edge', slot: 'weapon', rarity: 'legendary', bonus: { atk: 32 } },
++ {
++ id: 'sunsteel_edge',
++ name: 'Sunsteel Edge',
++ slot: 'weapon',
++ rarity: 'epic',
++ bonus: { atk: 18 },
++ },
++ {
++ id: 'tidewoven_robe',
++ name: 'Tidewoven Robe',
++ slot: 'armor',
++ rarity: 'epic',
++ bonus: { hp: 18, def: 12 },
++ },
++ { id: 'swift_feather', name: 'Swift Feather', slot: 'charm', rarity: 'rare', bonus: { spd: 6 } },
++]
++
++const quests: QuestDef[] = [
++ {
++ id: 'd_login',
++ period: 'daily',
++ text: 'Claim today’s login reward',
++ counter: 'login',
++ target: 1,
++ reward: { gems: 10 },
++ },
++ {
++ id: 'd_win3',
++ period: 'daily',
++ text: 'Win 3 battles',
++ counter: 'battle_won',
++ target: 3,
++ reward: { gold: 3000, gems: 10 },
++ },
++ {
++ id: 'd_idle2',
++ period: 'daily',
++ text: 'Collect idle rewards twice',
++ counter: 'idle_collected',
++ target: 2,
++ reward: { gold: 2000 },
++ },
++ {
++ id: 'd_level3',
++ period: 'daily',
++ text: 'Level up heroes 3 times',
++ counter: 'hero_levelled',
++ target: 3,
++ reward: { gems: 15 },
++ },
++ {
++ id: 'd_summon1',
++ period: 'daily',
++ text: 'Summon once',
++ counter: 'summoned',
++ target: 1,
++ reward: { gold: 2500 },
++ },
++ {
++ id: 'd_energy30',
++ period: 'daily',
++ text: 'Spend 30 energy',
++ counter: 'energy_spent',
++ target: 30,
++ reward: { sigils: 1 },
++ },
++ {
++ id: 'w_win30',
++ period: 'weekly',
++ text: 'Win 30 battles',
++ counter: 'battle_won',
++ target: 30,
++ reward: { sigils: 3, gems: 50 },
++ },
++ {
++ id: 'w_clear5',
++ period: 'weekly',
++ text: 'Clear 5 new stages',
++ counter: 'stage_cleared',
++ target: 5,
++ reward: { sigils: 3 },
++ },
++ {
++ id: 'w_summon10',
++ period: 'weekly',
++ text: 'Summon 10 times',
++ counter: 'summoned',
++ target: 10,
++ reward: { gems: 80 },
++ },
++ {
++ id: 'w_star1',
++ period: 'weekly',
++ text: 'Star up a hero',
++ counter: 'star_up',
++ target: 1,
++ reward: { sigils: 2 },
++ },
++]
++
++const store: ProductDef[] = [
++ {
++ id: 'energy_refill',
++ name: 'Energy refill',
++ text: '60 energy, above your cap if you like.',
++ gems: 50,
++ limit: { period: 'daily', count: 3 },
++ reward: { energy: 60 },
++ },
++ {
++ id: 'gold_crate',
++ name: 'Gold crate',
++ text: 'Two hours of idle gold at your stage.',
++ gems: 80,
++ limit: { period: 'daily', count: 3 },
++ reward: { idleMinutes: 120 },
++ },
++ {
++ id: 'featured_legendary',
++ name: 'Featured legendary shards',
++ text: '30 shards of this week’s featured legendary.',
++ gems: 450,
++ limit: { period: 'weekly', count: 1 },
++ reward: {},
++ featured: { rarity: 'legendary', n: 30 },
++ },
++ {
++ id: 'featured_epic',
++ name: 'Featured epic shards',
++ text: '20 shards of this week’s featured epic.',
++ gems: 180,
++ limit: { period: 'weekly', count: 1 },
++ reward: {},
++ featured: { rarity: 'epic', n: 20 },
++ },
++ {
++ id: 'sunsteel_edge',
++ name: 'Sunsteel Edge',
++ text: 'Weapon: attack +18%.',
++ gems: 400,
++ limit: { period: 'once', count: 1 },
++ reward: { gear: ['sunsteel_edge'] },
++ },
++ {
++ id: 'tidewoven_robe',
++ name: 'Tidewoven Robe',
++ text: 'Armour: HP +18%, defence +12%.',
++ gems: 400,
++ limit: { period: 'once', count: 1 },
++ reward: { gear: ['tidewoven_robe'] },
++ },
++ {
++ id: 'swift_feather',
++ name: 'Swift Feather',
++ text: 'Charm: speed +6.',
++ gems: 200,
++ limit: { period: 'once', count: 1 },
++ reward: { gear: ['swift_feather'] },
++ },
++]
++
++export const vibeHeroes: CasualPack = {
++ id: 'vibe-heroes',
++ name: 'Vibe Heroes',
++ tagline:
++ 'Collect heroes, fuse their shards and push through 100 stages, even while you are away.',
++ heroes,
++ enemies,
++ chapters,
++ stages: makeStages(),
++ gear,
++ quests,
++ calendar: [
++ { gold: 3000 },
++ { gems: 30 },
++ { sigils: 2 },
++ { gold: 8000 },
++ { gems: 50 },
++ { shards: [{ hero: 'bram', n: 10 }] },
++ { sigils: 5, gems: 80 },
++ ],
++ banner: {
++ id: 'prism_call',
++ name: 'Prism Call',
++ cost: 1,
++ odds: { legendary: 0.02, epic: 0.1, rare: 0.3, common: 0.58 },
++ pity: 50,
++ duplicateShards: { common: 5, rare: 10, epic: 20, legendary: 30 },
++ },
++ store,
++ start: { heroes: ['gus', 'tess', 'milo', 'rook'], wallet: { gold: 5000, gems: 100, sigils: 10 } },
++ energy: { max: 60, regenMs: 5 * 60_000 },
++ freeFirstClears: 10,
++ idle: { capMs: 12 * 3_600_000, goldPerMinBase: 15, goldPerMinPerStage: 3, gemsPerHour: 1 },
++ fuseShards: { common: 10, rare: 20, epic: 30, legendary: 50 },
++ starShards: [0, 10, 20, 30, 50, 80],
++ starGold: [0, 2000, 6000, 15000, 40000, 100000],
++ maxStars: 6,
++ levelsPerStar: 20,
++ teamSize: 5,
++}
ddiff --git a/packages/casual/src/index.ts b/packages/casual/src/index.ts
nnew file mode 100644
iindex 0000000..177c5ad
---- /dev/null
++++ b/packages/casual/src/index.ts
@@@ -0,0 +1,14 @@
++export * from './types.js'
++export * from './time.js'
++export * from './stats.js'
++export * from './battle.js'
++export * from './kit.js'
++export * from './actions.js'
++export * from './validate.js'
++export { vibeHeroes } from './content/vibeHeroes.js'
++
++import { vibeHeroes } from './content/vibeHeroes.js'
++import type { CasualPack } from './types.js'
++
++/** Every casual game, by id. */
++export const CASUAL_GAMES: Record<string, CasualPack> = { [vibeHeroes.id]: vibeHeroes }
ddiff --git a/packages/casual/src/kit.ts b/packages/casual/src/kit.ts
nnew file mode 100644
iindex 0000000..fc2704d
---- /dev/null
++++ b/packages/casual/src/kit.ts
@@@ -0,0 +1,162 @@
++/**
++ * Read-only views of a player's state at a time: energy, idle earnings, this week's featured heroes, resets and
++ * the red dots. Both sides use them; only the server's answers count.
++ */
++import { heroStats, power } from './stats.js'
++import { dayKey, nextDay, nextWeek, weekIndex, weekKey } from './time.js'
++import type { CasualPack, HeroDef, PlayerState, QuestDef, Rarity } from './types.js'
++
++export function heroDef(pack: CasualPack, id: string): HeroDef | undefined {
++ return pack.heroes.find((h) => h.id === id)
++}
++
++/** Energy now: it refills one point per `regenMs` up to the cap; refills above the cap stay until spent. */
++export function energyNow(pack: CasualPack, s: PlayerState, now: number): number {
++ const e = s.energy
++ if (e.value >= pack.energy.max) return e.value
++ const ticks = Math.max(0, Math.floor((now - e.at) / pack.energy.regenMs))
++ return Math.min(pack.energy.max, e.value + ticks)
++}
++
++/** When the next energy point arrives, or null when full. */
++export function nextEnergyAt(pack: CasualPack, s: PlayerState, now: number): number | null {
++ if (energyNow(pack, s, now) >= pack.energy.max) return null
++ const regen = pack.energy.regenMs
++ const ticks = Math.max(0, Math.floor((now - s.energy.at) / regen))
++ return s.energy.at + (ticks + 1) * regen
++}
++
++export function goldPerMinute(pack: CasualPack, s: PlayerState): number {
++ return pack.idle.goldPerMinBase + pack.idle.goldPerMinPerStage * s.stage.cleared
++}
++
++/** What the idle chest holds now (it stops filling at `capMs`). */
++export function idlePending(
++ pack: CasualPack,
++ s: PlayerState,
++ now: number,
++): { minutes: number; gold: number; gems: number; full: boolean } {
++ const ms = Math.max(0, Math.min(now - s.idle.at, pack.idle.capMs))
++ const minutes = Math.floor(ms / 60_000)
++ return {
++ minutes,
++ gold: minutes * goldPerMinute(pack, s),
++ gems: Math.floor(minutes / 60) * pack.idle.gemsPerHour,
++ full: now - s.idle.at >= pack.idle.capMs,
++ }
++}
++
++/** This week's featured legendary and epic (a public rotation by week number) and when it changes. */
++export function featuredFor(
++ pack: CasualPack,
++ now: number,
++): { legendary: string; epic: string; until: number } {
++ const w = weekIndex(now)
++ const of = (r: Rarity) => pack.heroes.filter((h) => h.rarity === r)
++ const legendaries = of('legendary')
++ const epics = of('epic')
++ return {
++ legendary: legendaries[w % legendaries.length]?.id ?? '',
++ epic: epics[w % epics.length]?.id ?? '',
++ until: nextWeek(now),
++ }
++}
++
++/**
++ * Each hero's chance on one draw, as published: a rarity's share is split evenly, except that the featured hero
++ * of that rarity takes half of it.
++ */
++export function drawOdds(
++ pack: CasualPack,
++ now: number,
++): { hero: string; rarity: Rarity; chance: number }[] {
++ const featured = featuredFor(pack, now)
++ const out: { hero: string; rarity: Rarity; chance: number }[] = []
++ for (const rarity of ['legendary', 'epic', 'rare', 'common'] as Rarity[]) {
++ const list = pack.heroes.filter((h) => h.rarity === rarity)
++ const share = pack.banner.odds[rarity]
++ const pick =
++ rarity === 'legendary' ? featured.legendary : rarity === 'epic' ? featured.epic : null
++ for (const h of list) {
++ const chance =
++ pick && list.length > 1
++ ? h.id === pick
++ ? share / 2
++ : share / 2 / (list.length - 1)
++ : share / list.length
++ out.push({ hero: h.id, rarity, chance })
++ }
++ }
++ return out
++}
++
++/** Quest progress for the current day and week (a stale period reads as zero). */
++export function questProgress(
++ s: PlayerState,
++ q: QuestDef,
++ now: number,
++): { n: number; claimed: boolean } {
++ const current =
++ q.period === 'daily' ? s.quests.day === dayKey(now) : s.quests.week === weekKey(now)
++ if (!current) return { n: 0, claimed: false }
++ // Daily and weekly quests share the counters map, keyed by period.
++ const key = `${q.period === 'daily' ? 'd' : 'w'}:${q.counter}`
++ const n = s.quests.progress[key] ?? 0
++ return { n, claimed: s.quests.claimed.includes(q.id) }
++}
++
++export function resets(now: number): { day: number; week: number } {
++ return { day: nextDay(now), week: nextWeek(now) }
++}
++
++export function teamPower(pack: CasualPack, s: PlayerState): number {
++ let total = 0
++ for (const id of s.team) {
++ const def = heroDef(pack, id)
++ const hs = s.heroes[id]
++ if (def && hs) total += power(heroStats(pack, def, hs))
++ }
++ return total
++}
++
++export function gemsTotal(s: PlayerState): number {
++ return s.wallet.gems + s.wallet.gemsPaid
++}
++
++/**
++ * The red dots: what is waiting to be claimed or can be done right now. Heroes count only a fuse or a star-up
++ * (levelling is always possible with some gold, and a dot that never goes away teaches players to ignore dots).
++ */
++export function notices(
++ pack: CasualPack,
++ s: PlayerState,
++ now: number,
++): { idle: boolean; login: boolean; quests: number; heroes: number; summon: boolean } {
++ let quests = 0
++ for (const q of pack.quests) {
++ const p = questProgress(s, q, now)
++ if (!p.claimed && p.n >= q.target) quests++
++ }
++ let heroes = 0
++ for (const h of pack.heroes) {
++ const hs = s.heroes[h.id]
++ const shards = s.shards[h.id] ?? 0
++ if (!hs) {
++ if (shards >= pack.fuseShards[h.rarity]) heroes++
++ continue
++ }
++ if (
++ hs.stars < pack.maxStars &&
++ shards >= (pack.starShards[hs.stars] ?? Infinity) &&
++ s.wallet.gold >= (pack.starGold[hs.stars] ?? Infinity)
++ )
++ heroes++
++ }
++ return {
++ idle: idlePending(pack, s, now).minutes >= 60,
++ login: s.login.last !== dayKey(now),
++ quests,
++ heroes,
++ summon: s.wallet.sigils >= pack.banner.cost,
++ }
++}
ddiff --git a/packages/casual/src/stats.ts b/packages/casual/src/stats.ts
nnew file mode 100644
iindex 0000000..4ac0a0d
---- /dev/null
++++ b/packages/casual/src/stats.ts
@@@ -0,0 +1,137 @@
++import type {
++ CasualPack,
++ Element,
++ GearDef,
++ HeroClass,
++ HeroDef,
++ HeroState,
++ Rarity,
++} from './types.js'
++
++export interface Stats {
++ hp: number
++ atk: number
++ def: number
++ spd: number
++}
++
++/** Level 1, one star, common, no gear. */
++export const CLASS_BASE: Record<HeroClass, Stats> = {
++ guardian: { hp: 520, atk: 36, def: 30, spd: 90 },
++ blade: { hp: 380, atk: 60, def: 18, spd: 104 },
++ arcanist: { hp: 300, atk: 64, def: 12, spd: 100 },
++ ranger: { hp: 320, atk: 64, def: 14, spd: 112 },
++ mender: { hp: 340, atk: 44, def: 16, spd: 98 },
++ herald: { hp: 360, atk: 46, def: 18, spd: 108 },
++}
++
++export const RARITY_MULT: Record<Rarity, number> = {
++ common: 1,
++ rare: 1.15,
++ epic: 1.32,
++ legendary: 1.5,
++}
++
++/** The stars a hero has when it is first unlocked. */
++export const BASE_STARS: Record<Rarity, number> = { common: 1, rare: 2, epic: 3, legendary: 4 }
++
++export const RARITY_ORDER: Rarity[] = ['common', 'rare', 'epic', 'legendary']
++
++export function levelMult(level: number): number {
++ return 1 + 0.1 * (level - 1)
++}
++
++export function starMult(stars: number): number {
++ return 1 + 0.2 * (stars - 1)
++}
++
++export function levelCap(pack: CasualPack, stars: number): number {
++ return pack.levelsPerStar * stars
++}
++
++/** Gold to go from `level` to `level + 1`. */
++export function levelCost(level: number): number {
++ return Math.round(20 * Math.pow(level, 1.6))
++}
++
++/** Gold for `times` levels from `level`. */
++export function levelCostFor(level: number, times: number): number {
++ let total = 0
++ for (let i = 0; i < times; i++) total += levelCost(level + i)
++ return total
++}
++
++export function scaled(base: Stats, mult: number, level: number, stars: number): Stats {
++ const m = mult * levelMult(level) * starMult(stars)
++ return {
++ hp: Math.round(base.hp * m),
++ atk: Math.round(base.atk * m),
++ def: Math.round(base.def * m),
++ spd: base.spd + Math.min(30, Math.floor(level / 4)),
++ }
++}
++
++export function applyGear(stats: Stats, gear: GearDef[]): Stats {
++ let atk = 0
++ let hp = 0
++ let def = 0
++ let spd = 0
++ for (const g of gear) {
++ atk += g.bonus.atk ?? 0
++ hp += g.bonus.hp ?? 0
++ def += g.bonus.def ?? 0
++ spd += g.bonus.spd ?? 0
++ }
++ return {
++ hp: Math.round(stats.hp * (1 + hp / 100)),
++ atk: Math.round(stats.atk * (1 + atk / 100)),
++ def: Math.round(stats.def * (1 + def / 100)),
++ spd: stats.spd + spd,
++ }
++}
++
++export function heroStats(pack: CasualPack, def: HeroDef, state: HeroState): Stats {
++ const base = scaled(CLASS_BASE[def.class], RARITY_MULT[def.rarity], state.level, state.stars)
++ const gear: GearDef[] = []
++ for (const id of Object.values(state.gear)) {
++ const g = pack.gear.find((x) => x.id === id)
++ if (g) gear.push(g)
++ }
++ return applyGear(base, gear)
++}
++
++/** One number for a hero's strength, shown beside it and used to rank teams. */
++export function power(stats: Stats): number {
++ return Math.round(stats.hp / 8 + stats.atk * 2.2 + stats.def * 1.6 + stats.spd)
++}
++
++/**
++ * An enemy's stats at a stage: they grow with the stage the way a hero grows with levels, plus a star step every
++ * 20 stages, so a team keeps pace by levelling and starring up.
++ */
++export function enemyStats(cls: HeroClass, level: number, stage: number, boss: boolean): Stats {
++ const tier = 1 + Math.floor((stage - 1) / 20)
++ const mult = 0.78 + 0.0035 * stage
++ const s = scaled(CLASS_BASE[cls], mult, level, tier)
++ if (!boss) return s
++ return {
++ hp: Math.round(s.hp * 3.5),
++ atk: Math.round(s.atk * 1.2),
++ def: Math.round(s.def * 1.2),
++ spd: s.spd,
++ }
++}
++
++/** Element advantage: flame > grove > tide > flame; dawn and dusk each beat the other. */
++export function advantage(attacker: Element, defender: Element): number {
++ const beats: Record<Element, Element[]> = {
++ flame: ['grove'],
++ grove: ['tide'],
++ tide: ['flame'],
++ dawn: ['dusk'],
++ dusk: ['dawn'],
++ }
++ if (beats[attacker].includes(defender)) return 1.3
++ if (beats[defender].includes(attacker) && attacker !== 'dawn' && attacker !== 'dusk') return 0.8
++ return 1
++}
ddiff --git a/packages/casual/src/time.ts b/packages/casual/src/time.ts
nnew file mode 100644
iindex 0000000..84e2752
---- /dev/null
++++ b/packages/casual/src/time.ts
@@@ -0,0 +1,27 @@
++/** Resets happen at 00:00 UTC (days) and Monday 00:00 UTC (weeks), the same for every player. */
++
++const DAY = 86_400_000
++
++/** `2026-10-14` for a time in that UTC day. */
++export function dayKey(now: number): string {
++ return new Date(now).toISOString().slice(0, 10)
++}
++
++/** The ISO week number since the epoch's first Monday (1970-01-05), stable and comparable. */
++export function weekIndex(now: number): number {
++ return Math.floor((now - 4 * DAY) / (7 * DAY))
++}
++
++export function weekKey(now: number): string {
++ return `w${weekIndex(now)}`
++}
++
++/** The next 00:00 UTC after `now`. */
++export function nextDay(now: number): number {
++ return (Math.floor(now / DAY) + 1) * DAY
++}
++
++/** The next Monday 00:00 UTC after `now`. */
++export function nextWeek(now: number): number {
++ return (weekIndex(now) + 1) * 7 * DAY + 4 * DAY
++}
ddiff --git a/packages/casual/src/types.ts b/packages/casual/src/types.ts
nnew file mode 100644
iindex 0000000..038fdf5
---- /dev/null
++++ b/packages/casual/src/types.ts
@@@ -0,0 +1,223 @@
++/**
++ * The casual kit's data model (plan T22). A game is a content pack (`CasualPack`): heroes, enemies, stages,
++ * gear, quests, the login calendar, the summon banner and the store, all data. A player's progress is one
++ * `PlayerState` document the server owns; the client only renders it and sends intents.
++ */
++
++export type Element = 'flame' | 'tide' | 'grove' | 'dawn' | 'dusk'
++export type HeroClass = 'guardian' | 'blade' | 'arcanist' | 'ranger' | 'mender' | 'herald'
++export type Rarity = 'common' | 'rare' | 'epic' | 'legendary'
++export type GearSlot = 'weapon' | 'armor' | 'charm'
++/** Wallet currencies. `gemsPaid` holds gems bought with money, kept apart from gems earned in play. */
++export type Currency = 'gold' | 'gems' | 'gemsPaid' | 'sigils'
++
++/** Parameters the client's portrait painter draws a character from. */
++export interface Look {
++ skin: string
++ hair: string
++ /** Index into the painter's hair shapes. */
++ hairStyle: number
++ eyes: string
++ outfit: string
++ trim: string
++ /** Index into the painter's accessories (0 = none). */
++ accessory: number
++}
++
++export interface SkillDef {
++ name: string
++ text: string
++ /** The class skill's strength: a damage or heal multiplier of attack, a shield share of max HP, or a buff share. */
++ power: number
++}
++
++export interface HeroDef {
++ id: string
++ name: string
++ title: string
++ rarity: Rarity
++ element: Element
++ class: HeroClass
++ skill: SkillDef
++ bio: string
++ look: Look
++}
++
++export type EnemyKind =
++ 'slime' | 'wisp' | 'golem' | 'imp' | 'drone' | 'beetle' | 'specter' | 'drake'
++
++export interface EnemyDef {
++ id: string
++ name: string
++ kind: EnemyKind
++ element: Element
++ class: HeroClass
++ boss: boolean
++}
++
++export interface ShardReward {
++ hero: string
++ n: number
++}
++
++/** Fixed contents: every reward and every product lists exactly what it gives. */
++export interface Reward {
++ gold?: number
++ /** Gold worth this many minutes of the idle chest at the player's stage (shown as gold before buying). */
++ idleMinutes?: number
++ gems?: number
++ sigils?: number
++ energy?: number
++ shards?: ShardReward[]
++ gear?: string[]
++}
++
++export interface StageDef {
++ /** 1-based. */
++ index: number
++ chapter: number
++ name: string
++ /** The enemies' level. */
++ level: number
++ enemies: string[]
++ energy: number
++ /** The first clear. */
++ first: Reward
++ /** Every clear after the first. */
++ repeat: Reward
++}
++
++export interface ChapterDef {
++ index: number
++ name: string
++ /** CSS colours for the chapter's backdrop. */
++ sky: [string, string]
++}
++
++export interface GearDef {
++ id: string
++ name: string
++ slot: GearSlot
++ rarity: Rarity
++ /** Percent bonuses to attack, HP and defence; a flat bonus to speed. */
++ bonus: { atk?: number; hp?: number; def?: number; spd?: number }
++}
++
++export type Counter =
++ | 'battle_won'
++ | 'stage_cleared'
++ | 'idle_collected'
++ | 'hero_levelled'
++ | 'summoned'
++ | 'energy_spent'
++ | 'star_up'
++ | 'login'
++
++export interface QuestDef {
++ id: string
++ period: 'daily' | 'weekly'
++ text: string
++ counter: Counter
++ target: number
++ reward: Reward
++}
++
++export type ProductLimit = { period: 'daily' | 'weekly' | 'once'; count: number }
++
++/**
++ * A store product. Prices are in gems; every product's contents are fixed and listed. A `featured` product's
++ * shards are of this week's featured hero of that rarity (the rotation is public: `featuredFor`).
++ */
++export interface ProductDef {
++ id: string
++ name: string
++ text: string
++ gems: number
++ limit: ProductLimit
++ reward: Reward
++ /** Instead of `reward`: `n` shards of this week's featured hero of that rarity. */
++ featured?: { rarity: 'legendary' | 'epic'; n: number }
++}
++
++export interface BannerDef {
++ id: string
++ name: string
++ /** Sigils per draw. Sigils are earned in play only; nothing sells them (ADR-055: never paid chance). */
++ cost: number
++ odds: Record<Rarity, number>
++ /** A legendary is certain on this draw when the ones before it had none. */
++ pity: number
++ /** Shards a duplicate turns into, by rarity. */
++ duplicateShards: Record<Rarity, number>
++}
++
++export interface CasualPack {
++ id: string
++ name: string
++ tagline: string
++ heroes: HeroDef[]
++ enemies: EnemyDef[]
++ chapters: ChapterDef[]
++ stages: StageDef[]
++ gear: GearDef[]
++ quests: QuestDef[]
++ /** One reward per day of a 7-day cycle; the cycle moves on each day a reward is claimed. */
++ calendar: Reward[]
++ banner: BannerDef
++ store: ProductDef[]
++ start: { heroes: string[]; wallet: Partial<Record<Currency, number>> }
++ energy: { max: number; regenMs: number }
++ /** First clears of stages up to this one cost no energy, so the first minutes are all play. */
++ freeFirstClears: number
++ idle: { capMs: number; goldPerMinBase: number; goldPerMinPerStage: number; gemsPerHour: number }
++ /** Shards that unlock a hero, by rarity. */
++ fuseShards: Record<Rarity, number>
++ /** Shards for each star after the current one: index = current stars. */
++ starShards: number[]
++ /** Gold for each star after the current one, per star: index = current stars. */
++ starGold: number[]
++ maxStars: number
++ /** The level cap per star. */
++ levelsPerStar: number
++ teamSize: number
++}
++
++export interface HeroState {
++ level: number
++ stars: number
++ gear: Partial<Record<GearSlot, string>>
++}
++
++/** A product's purchases in its current period (`key` is the day, the week or `once`). */
++export interface BuyCount {
++ key: string
++ n: number
++}
++
++export interface PlayerState {
++ v: 1
++ created: number
++ wallet: Record<Currency, number>
++ energy: { value: number; at: number }
++ heroes: Record<string, HeroState>
++ shards: Record<string, number>
++ /** Unequipped gear by id. */
++ gear: Record<string, number>
++ team: string[]
++ stage: { cleared: number; stars: Record<string, number> }
++ idle: { at: number }
++ /** Counters keyed `d:<counter>` for today and `w:<counter>` for this week; `claimed` holds quest ids. */
++ quests: { day: string; week: string; progress: Record<string, number>; claimed: string[] }
++ login: { last: string | null; count: number }
++ summon: { pity: number; total: number }
++ buys: Record<string, BuyCount>
++ stats: { battles: number; wins: number }
++}
++
++/** One ledger line: an asset (`gold`, `gems`, `gemsPaid`, `sigils`, `shard:<hero>`, `hero:<hero>`, `gear:<id>`). */
++export interface Delta {
++ asset: string
++ delta: number
++ balance: number
++ reason: string
++}
ddiff --git a/packages/casual/src/validate.ts b/packages/casual/src/validate.ts
nnew file mode 100644
iindex 0000000..7aca7f4
---- /dev/null
++++ b/packages/casual/src/validate.ts
@@@ -0,0 +1,85 @@
++import { RARITY_ORDER } from './stats.js'
++import type { CasualPack, Reward } from './types.js'
++
++/** Problems with a pack; the server refuses to start with any. */
++export function validatePack(pack: CasualPack): string[] {
++ const errors: string[] = []
++ const unique = (what: string, ids: string[]) => {
++ const seen = new Set<string>()
++ for (const id of ids) {
++ if (seen.has(id)) errors.push(`${what} ${id} is listed twice`)
++ seen.add(id)
++ if (!/^[a-z0-9_]{1,40}$/.test(id)) errors.push(`${what} id ${id} is not [a-z0-9_]`)
++ }
++ return seen
++ }
++ const heroes = unique(
++ 'hero',
++ pack.heroes.map((h) => h.id),
++ )
++ const enemies = unique(
++ 'enemy',
++ pack.enemies.map((e) => e.id),
++ )
++ const gear = unique(
++ 'gear',
++ pack.gear.map((g) => g.id),
++ )
++ unique(
++ 'quest',
++ pack.quests.map((q) => q.id),
++ )
++ unique(
++ 'product',
++ pack.store.map((p) => p.id),
++ )
++ const reward = (where: string, r: Reward) => {
++ for (const [k, v] of Object.entries(r)) {
++ if (typeof v === 'number' && (!Number.isSafeInteger(v) || v < 0))
++ errors.push(`${where}: ${k} ${v}`)
++ }
++ for (const s of r.shards ?? []) {
++ if (!heroes.has(s.hero)) errors.push(`${where}: shards of unknown hero ${s.hero}`)
++ if (!Number.isSafeInteger(s.n) || s.n <= 0) errors.push(`${where}: shard count ${s.n}`)
++ }
++ for (const g of r.gear ?? []) if (!gear.has(g)) errors.push(`${where}: unknown gear ${g}`)
++ }
++ for (const r of RARITY_ORDER) {
++ if (!pack.heroes.some((h) => h.rarity === r)) errors.push(`no ${r} hero`)
++ }
++ const odds = Object.values(pack.banner.odds).reduce((a, b) => a + b, 0)
++ if (Math.abs(odds - 1) > 1e-9) errors.push(`banner odds add up to ${odds}`)
++ pack.stages.forEach((s, i) => {
++ if (s.index !== i + 1) errors.push(`stage ${i + 1} has index ${s.index}`)
++ if (!s.enemies.length) errors.push(`stage ${s.index} has no enemies`)
++ for (const e of s.enemies)
++ if (!enemies.has(e)) errors.push(`stage ${s.index}: unknown enemy ${e}`)
++ if (!pack.chapters.some((c) => c.index === s.chapter))
++ errors.push(`stage ${s.index}: no chapter ${s.chapter}`)
++ reward(`stage ${s.index} first`, s.first)
++ reward(`stage ${s.index} repeat`, s.repeat)
++ })
++ pack.quests.forEach((q) => reward(`quest ${q.id}`, q.reward))
++ pack.calendar.forEach((r, i) => reward(`calendar day ${i + 1}`, r))
++ for (const p of pack.store) {
++ reward(`product ${p.id}`, p.reward)
++ if (!Number.isSafeInteger(p.gems) || p.gems <= 0)
++ errors.push(`product ${p.id}: price ${p.gems}`)
++ }
++ for (const h of pack.start.heroes) if (!heroes.has(h)) errors.push(`start hero ${h} is unknown`)
++ if (pack.start.heroes.length > pack.teamSize) errors.push('more start heroes than a team holds')
++ if (pack.starShards.length < pack.maxStars || pack.starGold.length < pack.maxStars)
++ errors.push('star costs do not cover every star')
++ return errors
++}
++
++/** A short fingerprint of the pack: the client sends it so a stale page reloads instead of playing old rules. */
++export function packDigest(pack: CasualPack): string {
++ const text = JSON.stringify(pack)
++ let h = 0x811c9dc5
++ for (let i = 0; i < text.length; i++) {
++ h ^= text.charCodeAt(i)
++ h = Math.imul(h, 0x01000193)
++ }
++ return (h >>> 0).toString(16).padStart(8, '0')
++}
ddiff --git a/packages/casual/tsconfig.json b/packages/casual/tsconfig.json
nnew file mode 100644
iindex 0000000..6a9a732
---- /dev/null
++++ b/packages/casual/tsconfig.json
@@@ -0,0 +1,9 @@
++{
++ "extends": "../../tsconfig.base.json",
++ "compilerOptions": {
++ "rootDir": "src",
++ "outDir": "dist"
++ },
++ "include": ["src"],
++ "references": [{ "path": "../shared" }]
++}
ddiff --git a/packages/persistence/migrations/0007_casual.sql b/packages/persistence/migrations/0007_casual.sql
nnew file mode 100644
iindex 0000000..30edcc7
---- /dev/null
++++ b/packages/persistence/migrations/0007_casual.sql
@@@ -0,0 +1,43 @@
++-- phase: expand
++-- T22: the casual kit. One save per player per casual game (the server-owned PlayerState), the ledger of every
++-- currency and item change, and the idempotency record of every applied action.
++CREATE TABLE casual_saves (
++ game_id text COLLATE "C" NOT NULL,
++ account_key text COLLATE "C" NOT NULL,
++ state jsonb NOT NULL,
++ version integer NOT NULL DEFAULT 0,
++ player_name text,
++ stage integer NOT NULL DEFAULT 0,
++ power integer NOT NULL DEFAULT 0,
++ -- Where the player first came from (an ad link's src and c), first-party only.
++ source text,
++ campaign text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (game_id, account_key)
++);
++CREATE INDEX casual_saves_board ON casual_saves (game_id, stage DESC, power DESC, updated_at);
++CREATE INDEX casual_saves_account ON casual_saves (account_key);
++
++CREATE TABLE casual_ledger (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ game_id text COLLATE "C" NOT NULL,
++ account_key text COLLATE "C" NOT NULL,
++ action_key text COLLATE "C" NOT NULL,
++ asset text COLLATE "C" NOT NULL,
++ delta bigint NOT NULL,
++ balance bigint NOT NULL,
++ reason text NOT NULL,
++ at bigint NOT NULL
++);
++CREATE INDEX casual_ledger_account ON casual_ledger (game_id, account_key, id);
++
++CREATE TABLE casual_actions (
++ game_id text COLLATE "C" NOT NULL,
++ account_key text COLLATE "C" NOT NULL,
++ action_key text COLLATE "C" NOT NULL,
++ response jsonb NOT NULL,
++ at bigint NOT NULL,
++ PRIMARY KEY (game_id, account_key, action_key)
++);
++CREATE INDEX casual_actions_age ON casual_actions (at);
ddiff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts
iindex 7d1fc07..f1708f4 100644
---- a/packages/persistence/src/index.ts
++++ b/packages/persistence/src/index.ts
@@@ -4,3 +4,4 @@ export { accountKey } from './accountKey.js'
export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/pgStore.js'
export { InstanceRegistry, type InstanceLease, type InstanceState } from './pg/instanceRegistry.js'
export { MIGRATIONS_DIR } from './migrations.js'
++export { moveCasualSaves, exportCasual, eraseCasual } from './pg/casualData.js'
ddiff --git a/packages/persistence/src/pg/casualData.ts b/packages/persistence/src/pg/casualData.ts
nnew file mode 100644
iindex 0000000..f7189aa
---- /dev/null
++++ b/packages/persistence/src/pg/casualData.ts
@@@ -0,0 +1,59 @@
++import { sql, type Db } from 'openvibe-sdk/db'
++import { idList } from './sqlUtils.js'
++
++/**
++ * The casual kit's saves follow their owner (plan T22): a guest's save moves into the account that signs in on
++ * that browser, a folded-in account's save moves on a merge, and both are in the export and the erasure. One save
++ * per game per account: when the receiving account already has a save for a game, the other one stays where it is
++ * (kept, never deleted), the way a character with no free slot is kept.
++ */
++
++/** Moves `from`'s casual saves, their ledger and their action records to `into`; returns the games moved. */
++export async function moveCasualSaves(db: Db, from: string, into: string): Promise<string[]> {
++ if (from === into) return []
++ const games = (
++ await db.many<{ game_id: string }>(sql`
++ SELECT s.game_id FROM casual_saves s
++ WHERE s.account_key = ${from}
++ AND NOT EXISTS (
++ SELECT 1 FROM casual_saves t WHERE t.game_id = s.game_id AND t.account_key = ${into}
++ )
++ `)
++ ).map((r) => r.game_id)
++ if (!games.length) return []
++ await db.exec(
++ sql`UPDATE casual_saves SET account_key = ${into} WHERE account_key = ${from} AND game_id IN (${idList(games)})`,
++ )
++ await db.exec(
++ sql`UPDATE casual_ledger SET account_key = ${into} WHERE account_key = ${from} AND game_id IN (${idList(games)})`,
++ )
++ await db.exec(
++ sql`DELETE FROM casual_actions WHERE account_key = ${from} AND game_id IN (${idList(games)})`,
++ )
++ return games
++}
++
++export async function exportCasual(
++ db: Db,
++ subject: string,
++): Promise<{ saves: Record<string, unknown>[]; ledger: Record<string, unknown>[] }> {
++ const saves = await db.many<Record<string, unknown>>(sql`
++ SELECT game_id, state, player_name, stage, power, source, campaign, created_at, updated_at
++ FROM casual_saves WHERE account_key = ${subject} ORDER BY game_id
++ `)
++ const ledger = await db.many<Record<string, unknown>>(sql`
++ SELECT game_id, asset, delta, balance, reason, at
++ FROM casual_ledger WHERE account_key = ${subject} ORDER BY id
++ `)
++ return { saves, ledger }
++}
++
++export async function eraseCasual(db: Db, subjects: string[]): Promise<number> {
++ if (!subjects.length) return 0
++ const saves = await db.exec(
++ sql`DELETE FROM casual_saves WHERE account_key IN (${idList(subjects)})`,
++ )
++ await db.exec(sql`DELETE FROM casual_ledger WHERE account_key IN (${idList(subjects)})`)
++ await db.exec(sql`DELETE FROM casual_actions WHERE account_key IN (${idList(subjects)})`)
++ return saves
++}
ddiff --git a/packages/persistence/src/pg/platformRepositories.test.ts b/packages/persistence/src/pg/platformRepositories.test.ts
iindex ffee9cc..20d950a 100644
---- a/packages/persistence/src/pg/platformRepositories.test.ts
++++ b/packages/persistence/src/pg/platformRepositories.test.ts
@@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
--import { sql } from 'openvibe-sdk/db'
++import { sql, type Db } from 'openvibe-sdk/db'
import type { PlayerDto } from '../dto.js'
import { openTestStore } from '../testing.js'
++import { moveCasualSaves } from './casualData.js'
const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
const OTHER = 'usr_01JZZZZZZZZZZZZZZZZZZZZZZZ'
@@@ -136,7 +137,24 @@ describe('account export and deletion (ADR-033)', () => {
INSERT INTO identity_adoptions (adoption_key, subject_id, source, adopted_at)
VALUES ('merge:mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', ${SUBJECT}, 'merge', 1)
`)
++ for (const key of [SUBJECT, ALIAS, OTHER]) {
++ await store.db.exec(sql`
++ INSERT INTO casual_saves (game_id, account_key, state, stage, created_at, updated_at)
++ VALUES ('vibe-heroes', ${key}, '{"v":1}'::jsonb, 3, 1, 2)
++ `)
++ await store.db.exec(sql`
++ INSERT INTO casual_ledger (game_id, account_key, action_key, asset, delta, balance, reason, at)
++ VALUES ('vibe-heroes', ${key}, 'start', 'gold', 5000, 5000, 'start', 1)
++ `)
++ }
const out = await store.identity.exportSubject(SUBJECT)
++ expect(out.casualSaves).toEqual([
++ expect.objectContaining({ game_id: 'vibe-heroes', state: { v: 1 }, stage: 3 }),
++ ])
++ expect(out.casualSaves[0]).not.toHaveProperty('account_key')
++ expect(out.casualLedger).toEqual([
++ { game_id: 'vibe-heroes', asset: 'gold', delta: 5000, balance: 5000, reason: 'start', at: 1 },
++ ])
expect(out.characters.map((c) => c.id)).toEqual(['d0'])
expect(out.characters[0]).not.toHaveProperty('token')
expect(out.characters[0]).not.toHaveProperty('account_key')
@@@ -146,7 +164,11 @@ describe('account export and deletion (ADR-033)', () => {
characters: 2,
structures_unowned: 1,
identity_rows: 1,
++ casual_saves: 2,
})
++ expect(
++ await store.db.many(sql`SELECT account_key FROM casual_ledger ORDER BY account_key`),
++ ).toEqual([{ account_key: OTHER }])
expect(await store.players.listByToken(SUBJECT)).toEqual([])
expect(await store.players.listByToken(ALIAS)).toEqual([])
expect((await store.players.listByToken(OTHER)).map((p) => p.id)).toEqual(['o0'])
@@@ -162,10 +184,44 @@ describe('account export and deletion (ADR-033)', () => {
characters: 0,
structures_unowned: 0,
identity_rows: 0,
++ casual_saves: 0,
})
})
})
++describe('casual saves follow their owner (plan T22)', () => {
++ const GUEST = 'guest:' + 'a'.repeat(64)
++ const save = (db: Db, key: string, game: string, stage: number) =>
++ db.exec(sql`
++ INSERT INTO casual_saves (game_id, account_key, state, stage, created_at, updated_at)
++ VALUES (${game}, ${key}, '{"v":1}'::jsonb, ${stage}, 1, 1)
++ `)
++ it('moves a save into an account with none for that game, and keeps the other where it is', async () => {
++ const store = await openTestStore()
++ await save(store.db, GUEST, 'vibe-heroes', 7)
++ await save(store.db, GUEST, 'other-game', 2)
++ await save(store.db, SUBJECT, 'other-game', 9)
++ await store.db.exec(sql`
++ INSERT INTO casual_ledger (game_id, account_key, action_key, asset, delta, balance, reason, at)
++ VALUES ('vibe-heroes', ${GUEST}, 'start', 'gems', 100, 100, 'start', 1)
++ `)
++ expect(await moveCasualSaves(store.db, GUEST, SUBJECT)).toEqual(['vibe-heroes'])
++ expect(
++ await store.db.many(
++ sql`SELECT game_id, account_key, stage FROM casual_saves ORDER BY game_id, account_key`,
++ ),
++ ).toEqual([
++ { game_id: 'other-game', account_key: GUEST, stage: 2 },
++ { game_id: 'other-game', account_key: SUBJECT, stage: 9 },
++ { game_id: 'vibe-heroes', account_key: SUBJECT, stage: 7 },
++ ])
++ expect(await store.db.many(sql`SELECT account_key FROM casual_ledger`)).toEqual([
++ { account_key: SUBJECT },
++ ])
++ expect(await moveCasualSaves(store.db, GUEST, SUBJECT)).toEqual([])
++ })
++})
++
describe('player subject_id', () => {
it('keeps subject_id through ordinary saves that do not carry it', async () => {
const store = await openTestStore()
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex 33a7a77..6fd3e73 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -11,6 +11,7 @@ import type {
} from '../dto.js'
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
import { idList } from './sqlUtils.js'
++import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'
/**
* PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry
@@@ -87,6 +88,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
ON CONFLICT (account_key, extraction_id) DO NOTHING
`)
await db.exec(sql`DELETE FROM game_hauls WHERE account_key = ${from}`)
++ await moveCasualSaves(db, from, into)
}
const recordAdoption = (
key: string,
@@@ -188,6 +190,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
const gameHauls = await db.many<Record<string, unknown>>(
sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,
)
++ const casual = await exportCasual(db, subject)
return {
characters: characters.map(withoutAccountKey),
structures,
@@@ -195,12 +198,14 @@ export function createIdentityRepository(db: Db): IdentityRepository {
gameBestScores,
gameAchievements,
gameHauls,
++ casualSaves: casual.saves,
++ casualLedger: casual.ledger,
}
},
eraseSubjects(subjects) {
return db.tx(async () => {
-- const out = { characters: 0, structures_unowned: 0, identity_rows: 0 }
++ const out = { characters: 0, structures_unowned: 0, identity_rows: 0, casual_saves: 0 }
if (!subjects.length) return out
const ids = (
await db.many<{ id: string }>(
@@@ -223,6 +228,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
sql`DELETE FROM game_round_results WHERE account_key IN (${idList(subjects)})`,
)
await db.exec(sql`DELETE FROM game_hauls WHERE account_key IN (${idList(subjects)})`)
++ out.casual_saves = await eraseCasual(db, subjects)
return out
})
},
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 65c3e0f..d1386c9 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -88,6 +88,9 @@ export interface IdentityRepository {
gameBestScores: Record<string, unknown>[]
gameAchievements: Record<string, unknown>[]
gameHauls: Record<string, unknown>[]
++ /** Casual-kit saves (plan T22) and their ledger. */
++ casualSaves: Record<string, unknown>[]
++ casualLedger: Record<string, unknown>[]
}>
/**
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
@@@ -98,6 +101,7 @@ export interface IdentityRepository {
characters: number
structures_unowned: number
identity_rows: number
++ casual_saves: number
}>
}
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex 7e68375..20e064c 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -17,6 +17,9 @@ const TABLES = [
'game_best_scores',
'game_achievements',
'game_hauls',
++ 'casual_saves',
++ 'casual_ledger',
++ 'casual_actions',
'game_instances',
'character_instance_positions',
'world_meta',
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 572066d..0d1de0d 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -56,6 +56,9 @@ importers:
'@babylonjs/procedural-textures':
specifier: ^9.20.0
version: 9.20.0(@babylonjs/[email protected])
++ '@openvibe/casual':
++ specifier: workspace:*
++ version: link:../../packages/casual
'@openvibe/content':
specifier: workspace:*
version: link:../../packages/content
@@@ -87,6 +90,9 @@ importers:
'@electric-sql/pglite':
specifier: ^0.5.8
version: 0.5.8
++ '@openvibe/casual':
++ specifier: workspace:*
++ version: link:../../packages/casual
'@openvibe/content':
specifier: workspace:*
version: link:../../packages/content
@@@ -140,6 +146,12 @@ importers:
specifier: ^0.25.2
version: 0.25.12
++ packages/casual:
++ dependencies:
++ '@openvibe/shared':
++ specifier: workspace:*
++ version: link:../shared
++
packages/content:
dependencies:
'@openvibe/shared':
ddiff --git a/test/apps__server__src__casual__casual.test.js b/test/apps__server__src__casual__casual.test.js
nnew file mode 100644
iindex 0000000..2e55182
---- /dev/null
++++ b/test/apps__server__src__casual__casual.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/casual/casual.test.ts'
++// covers: 'apps/server/src/casual'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/packages__casual__src__casual.test.js b/test/packages__casual__src__casual.test.js
nnew file mode 100644
iindex 0000000..9d1de99
---- /dev/null
++++ b/test/packages__casual__src__casual.test.js
@@@ -0,0 +1,3 @@
++// file: 'packages/casual/src/casual.test.ts'
++// covers: 'packages/casual/src'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/tsconfig.solution.json b/tsconfig.solution.json
iindex 940266d..6097574 100644
---- a/tsconfig.solution.json
++++ b/tsconfig.solution.json
@@@ -7,6 +7,7 @@
{ "path": "packages/gameplay" },
{ "path": "packages/physics" },
{ "path": "packages/persistence" },
++ { "path": "packages/casual" },
{ "path": "apps/server" },
{ "path": "apps/client" }
]