diff --git a/AGENTS.md b/AGENTS.md index 7f6faea..79142e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,22 @@ onPlayers((players) => { /* seat -> {agentId,name,avatar}; place where you like Rules you **MUST** follow: +- **Pacing**: frames arrive when the HOST decides, which on a finished replay can + be as fast as it likes. If a frame takes time to draw — an animation, or a beat + you want the viewer to register — **return a promise** and the SDK holds the + next frame until it settles, and tells the host when you are ready for one: + + ```ts + onFrame(async (frame, root) => { + drawBoard(frame, root) + await animateMove(frame) + await wait(HOLD_MS) // let the finished move sit there + }, { paceMs: HOLD_MS }) // roughly how long a frame takes — a hint for budgeting + ``` + + Do **NOT** hand-roll a queue and a `busy` flag for this; the SDK does it, and a + view that draws instantly should say so (`{ paceMs: 0 }`) so a replay host can + fit more of a long match in. See [spec §5b](spec/protocol.md). - **Identity**: your game logic sees only opaque agent ids. Names/avatars arrive in the view via `onPlayers` — never invent or hardcode them. - **Hidden info**: set `meta.hiddenInfo: true` and make `render` viewer-aware — diff --git a/games/chinese-checkers/view.ts b/games/chinese-checkers/view.ts index a24e10e..1d225dc 100644 --- a/games/chinese-checkers/view.ts +++ b/games/chinese-checkers/view.ts @@ -15,7 +15,7 @@ * 4. identity — names and avatars arrive separately via `onPlayers`, and may * land before or after the first frame. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { hold, onFrame, playbackSpeed, onPlayers } from '@arena/game-sdk/view' import { ARENA_THEME as T } from '@arena/game-sdk/theme' import type { PlayerInfo } from '@arena/game-sdk' @@ -438,107 +438,104 @@ function paint(frame: Frame, skip: Set, trail: number[]): void { } } -// —— frame queue —————————————————————————————————————————————————————— +// —— frame handling ———————————————————————————————————————————————————— +// +// The SDK owns the queue and the one-at-a-time discipline; a frame that takes +// time says so by returning a promise, which also tells the host when to send +// the next one. What is left here is what is specific to this board. -const queue: Frame[] = [] let shown: Frame | null = null -let busy = false let lastKey: string | null = null /** Identifies a position; consecutive frames sharing one are the same move. */ const frameKey = (f: Frame): string => `${f.ply ?? -1}|${f.status ?? ''}|${(f.lastPath ?? []).join(',')}` -function draw(raw: unknown, root: HTMLElement): void { +function draw(raw: unknown, root: HTMLElement): void | Promise { ensureDom(root) + if (!canvas) return const frame = raw as Frame // The host's replay timer clamps at the last index and then re-posts that // frame forever (see replayFrames in the SDK). Taking it at face value would - // replay the winning move on a loop, so identical repeats are dropped. + // replay the winning move on a loop, so identical repeats are dropped. They + // are still acked (by returning), so a host waiting on us is not left hanging. const key = frameKey(frame) if (key === lastKey) return lastKey = key - queue.push(frame) - pump() -} - -function pump(): void { - if (busy || queue.length === 0 || !canvas) return - const next = queue.shift()! - const path = next.lastPath ?? [] + const path = frame.lastPath ?? [] // First frame, a pass, or a rewind — snap rather than animate. if (!shown || path.length < 2) { - shown = next - renderHud(next, -1) - setStatus(statusText(next)) - paint(next, new Set(), []) - pump() + shown = frame + renderHud(frame, -1) + setStatus(statusText(frame)) + paint(frame, new Set(), []) return } - busy = true // A frame carries `lastPath` (the move just made) but `side` (whoever is due // next). While the peg is in flight the HUD must follow the seat that owns // it, or the highlight reads as the wrong player for the whole animation. - const mover = next.pegs?.[path[path.length - 1]!] ?? 0 - renderHud(next, mover) - setStatus(moveText(next, mover, path)) - animate(next, path) + const mover = frame.pegs?.[path[path.length - 1]!] ?? 0 + renderHud(frame, mover) + setStatus(moveText(frame, mover, path)) + return animate(frame, path) } -function animate(frame: Frame, path: number[]): void { +function animate(frame: Frame, path: number[]): Promise { const g = canvas?.getContext('2d') - if (!g) return + if (!g) return Promise.resolve() const dest = path[path.length - 1]! const seat = frame.pegs?.[dest] ?? 0 const skip = new Set([dest]) const legs = path.length - 1 - const total = legs * HOP_MS + const total = (legs * HOP_MS) / playbackSpeed() const jumping = legs > 1 || path.length > 2 let started = -1 - const tick = (now: number): void => { - if (started < 0) started = now - const t = Math.min(1, (now - started) / total) - - // Which leg we are on, and how far along it. - const walked = t * legs - const leg = Math.min(legs - 1, Math.floor(walked)) - const u = walked - leg - const [ax, ay] = holeXY(frame, path[leg]!) - const [bx, by] = holeXY(frame, path[leg + 1]!) - const ease = u * u * (3 - 2 * u) // smoothstep within the leg - const x = ax + (bx - ax) * ease - const y = ay + (by - ay) * ease - // A jump arcs over the peg it clears; a single step slides flat. - const lift = jumping ? Math.sin(Math.PI * ease) * SCALE * 0.55 : 0 - - paint(frame, skip, path.slice(0, leg + 2)) - peg(g, x, y - lift, seat, lift) - - if (t < 1) { - requestAnimationFrame(tick) - return + return new Promise((resolve) => { + const tick = (now: number): void => { + if (started < 0) started = now + const t = Math.min(1, (now - started) / total) + + // Which leg we are on, and how far along it. + const walked = t * legs + const leg = Math.min(legs - 1, Math.floor(walked)) + const u = walked - leg + const [ax, ay] = holeXY(frame, path[leg]!) + const [bx, by] = holeXY(frame, path[leg + 1]!) + const ease = u * u * (3 - 2 * u) // smoothstep within the leg + const x = ax + (bx - ax) * ease + const y = ay + (by - ay) * ease + // A jump arcs over the peg it clears; a single step slides flat. + const lift = jumping ? Math.sin(Math.PI * ease) * SCALE * 0.55 : 0 + + paint(frame, skip, path.slice(0, leg + 2)) + peg(g, x, y - lift, seat, lift) + + if (t < 1) { + requestAnimationFrame(tick) + return + } + // The peg has landed: hand the highlight over to whoever is due next. + shown = frame + paint(frame, new Set(), path) + renderHud(frame, -1) + setStatus(statusText(frame)) + void hold(HOLD_MS).then(resolve) } - // The peg has landed: hand the highlight over to whoever is due next. - shown = frame - paint(frame, new Set(), path) - renderHud(frame, -1) - setStatus(statusText(frame)) - setTimeout(() => { - busy = false - pump() - }, HOLD_MS) - } - requestAnimationFrame(tick) + requestAnimationFrame(tick) + }) } // —— wiring —————————————————————————————————————————————————————————— -onFrame(draw) +// A hop chain's length varies per move, so this pace is the common case (a +// single hop plus the dwell) rather than a bound — the promise above is what +// actually tells the host when each move has landed. +onFrame(draw, { paceMs: HOP_MS + HOLD_MS }) // Identity can arrive before or after the frames; refresh whatever is on screen. onPlayers((p) => { diff --git a/games/doudizhu/view.ts b/games/doudizhu/view.ts index f73db76..3e36f04 100644 --- a/games/doudizhu/view.ts +++ b/games/doudizhu/view.ts @@ -13,7 +13,7 @@ * the viewer's own hand, which we still render as a single stack for a tidy table). * Identity (name/avatar) arrives via `onPlayers`; author logic never sees it. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { dwell, onFrame, onPlayers } from '@arena/game-sdk/view' import type { PlayerInfo } from '@arena/game-sdk' interface SeatView { @@ -293,32 +293,23 @@ function draw(): void { } // —— playback pacing —— -// The platform controls when it pushes frames; on replay it bursts the whole -// match in at once, which flashes by. We buffer frames and play them out no -// faster than HOLD_MS apart. A live match (frames minutes apart) never fills the -// queue, so it stays responsive. +// The platform controls when it pushes frames; on replay it may push the whole +// match in at once, which flashes by. Each play stays up for HOLD_MS, and the +// SDK holds the next frame until then — which is also what tells the host we are +// ready for it, so a host that waits never builds a backlog. +// +// This used to buffer and drain on its own timer, and only held a frame when +// something was already queued behind it. Under a host that sends one frame at a +// time there is never anything queued, so that version would have stopped +// pausing on each play altogether. const HOLD_MS = 1700 // minimum time each step (a play / pass / bid) is shown -const frameQueue: DouFrame[] = [] -let busy = false - -function pump(): void { - if (busy || frameQueue.length === 0) return - lastFrame = frameQueue.shift()! - draw() - if (frameQueue.length > 0) { - busy = true - setTimeout(() => { - busy = false - pump() - }, HOLD_MS) - } -} onFrame((frame, h) => { ensureRoot(h) - frameQueue.push(frame as DouFrame) - pump() -}) + lastFrame = frame as DouFrame + draw() + return dwell(HOLD_MS) +}, { paceMs: HOLD_MS }) onPlayers((p) => { players = p draw() // identity applies immediately to the current frame diff --git a/games/gomoku/view.ts b/games/gomoku/view.ts index 41766e1..1fd74d8 100644 --- a/games/gomoku/view.ts +++ b/games/gomoku/view.ts @@ -191,10 +191,15 @@ function render(root: HTMLElement): void { drawBoard() } +// Nothing here animates — a stone is either on the board or it is not — so a +// frame is finished the moment it is drawn. Saying so (`paceMs: 0`) rather than +// staying silent is what lets a replay host fit a long game in: a host with no +// information has to assume the slow end, and assuming ~2s a move for a +// 226-move match meant showing about one move in eight. onFrame((frame, root) => { lastFrame = frame render(root) -}) +}, { paceMs: 0 }) onPlayers((p) => { players = p diff --git a/games/liars-dice/src/liars-dice.game.ts b/games/liars-dice/src/liars-dice.game.ts index 7f90464..f683b64 100644 --- a/games/liars-dice/src/liars-dice.game.ts +++ b/games/liars-dice/src/liars-dice.game.ts @@ -65,6 +65,20 @@ interface State { side: number // mirror of `turn` for SDK convention round: number // increments each time cups are re-rolled reveal: Reveal | null // most recent open-cup result (public) + /** + * A challenge has resolved and the next round has NOT been opened yet. + * + * The challenge used to do both at once, and the single state it returned + * carried the opened cups AND the next round already under way — re-rolled, + * `round` incremented, a new seat to act. Since the platform records one frame + * per step, that made one frame mean three things, and a replay showed a seat + * bid and then jumped to a different seat opening a later round, with the + * "LIAR?" moment labelled as the round after the one it settled. + * + * So the challenge stops at its own outcome, and `openRound` starts the next + * round on the next action. One step, one thing that happened. + */ + pendingRoll: boolean eliminationOrder: number[] // seats in the order they were knocked out finisher: number // winning seat once done, -1 while unfinished } @@ -296,6 +310,27 @@ function decide(v: View, p: Params): Action { return { bid: best } } +/** + * Open the round a resolved challenge left pending: re-roll every living cup, + * count the round, and clear the challenge that ended the last one. + * + * The ONLY caller is `step`, and only after the incoming action has been found + * legal. That order matters: `roll` draws from `ctx.random`, and drawing before a + * `ctx.reject` would advance the random stream on an action that never happened, + * so a replay of the same match would deal different dice. + */ +function openRound(s: State, ctx: Ctx): State { + if (!s.pendingRoll) return s + return { + ...s, + dice: s.dice.map((d, i) => (s.alive[i] ? roll(d.length, ctx) : d)), + pendingRoll: false, + reveal: null, + round: s.round + 1, + lastBid: [null, null, null], // fresh round → clear each seat's shown bid + } +} + /** * The rule kernel both paces run on. `seat` is the mover — resolved from * `ctx.actor` in turn-based pace, from `state.turn` in strategy pace — and is @@ -312,13 +347,18 @@ function step(s: State, seat: number, action: Action, ctx: Ctx): State { const c = count as number const f = face as number if (f < 1 || f > 6 || c < 1) ctx.reject('bad-bid') + // Every check here reads dice COUNTS and the standing bid, never a face, so + // it gives the same answer before and after a pending re-roll — which is what + // lets the whole action be validated before any randomness is drawn. if (c > livingDice(s)) ctx.reject('impossible-bid') // can't claim more dice than exist const next: Bid = { count: c, face: f } if (!raises(s.bid, next)) ctx.reject('bad-bid') // must strictly out-bid the standing bid - const turn = nextAlive(seat, s.alive) - const lastBid = [...s.lastBid] + + const open = openRound(s, ctx) + const turn = nextAlive(seat, open.alive) + const lastBid = [...open.lastBid] lastBid[seat] = next - return { ...s, bid: next, bidder: seat, lastBid, turn, side: turn, reveal: null } + return { ...open, bid: next, bidder: seat, lastBid, turn, side: turn, reveal: null } } // —— challenge the standing bid —— @@ -349,22 +389,27 @@ function step(s: State, seat: number, action: Action, ctx: Ctx): State { return { ...s, phase: 'done', dice, alive, eliminationOrder, reveal, finisher, bid: null, bidder: -1 } } - // Next round: re-roll every living cup; the loser leads (or the next living - // seat if the loser was just knocked out). - const rolled = dice.map((d, i) => (alive[i] ? roll(d.length, ctx) : d)) + /** + * Stop at the outcome. The cups are open, a die is gone, and this round is + * over — but the next one has not started: no re-roll, `round` unchanged, and + * `lastBid` still holding what each seat claimed, so the frame this produces + * can show what was bid alongside what was actually on the table. + * + * `openRound` does the rest on the leader's next action. The loser leads, or + * the next living seat if the loser was just knocked out. + */ const turn = alive[loser] ? loser : nextAlive(loser, alive) return { ...s, - dice: rolled, + dice, alive, eliminationOrder, reveal, + pendingRoll: true, bid: null, bidder: -1, - lastBid: [null, null, null], // fresh round → clear each seat's shown bid turn, side: turn, - round: s.round + 1, } } @@ -408,6 +453,7 @@ export default defineGame({ side: 0, round: 1, reveal: null, + pendingRoll: false, eliminationOrder: [], finisher: -1, }), @@ -481,6 +527,16 @@ export default defineGame({ let status: string if (s.phase === 'done') { status = `Seat ${s.finisher} wins` + } else if (s.pendingRoll && s.reveal) { + // The round that just ENDED — this frame is the challenge being settled, + // not the next round. It used to read "Round · Seat N to open", + // which named the wrong round and said nothing about the challenge that + // was on screen. + const r = s.reveal + const fate = r.eliminated !== null ? `Seat ${r.loser} loses last die — out` : `Seat ${r.loser} loses a die` + status = + `Round ${s.round} · Seat ${r.challenger} challenges ${r.bid.count}×${r.bid.face} · ` + + `${r.actual} on the table · ${fate}` } else if (s.bid) { status = `Round ${s.round} · Seat ${s.bidder} bids ${s.bid.count}×${s.bid.face} · Seat ${s.turn} to act` } else { @@ -498,6 +554,9 @@ export default defineGame({ turn: s.turn, bidder: s.bidder, bid: s.bid, + // Lets the view hold the open-cup beat on its own, rather than inferring + // it from `reveal` being present on a frame that had already moved on. + pendingRoll: s.pendingRoll, lastBid: s.lastBid, reveal: s.reveal, seats, diff --git a/games/liars-dice/test/liars-dice.test.ts b/games/liars-dice/test/liars-dice.test.ts index e857e18..469c1c0 100644 --- a/games/liars-dice/test/liars-dice.test.ts +++ b/games/liars-dice/test/liars-dice.test.ts @@ -75,7 +75,7 @@ describe('liars-dice · bidding', () => { // ———————————————————————————————————————————————————————————————— describe('liars-dice · challenge resolution', () => { - it('bid holds (with wild 1s counted) → challenger loses a die, then re-rolls', () => { + it('bid holds (with wild 1s counted) → challenger loses a die', () => { // face 5 across cups: seat0 [5,5]=2, seat1 [5,1]=2 (the 1 is wild), seat2 [2,2]=0 → actual 4 const s = base({ dice: [[5, 5], [5, 1], [2, 2]], @@ -93,7 +93,57 @@ describe('liars-dice · challenge resolution', () => { expect(n.alive).toEqual([true, true, true]) expect(n.bid).toBeNull() expect(n.turn).toBe(1) // loser leads the next round - expect(n.round).toBe(2) + + // The challenge stops at its own outcome: this state is the open cups, and + // the next round has NOT begun. Both used to happen in this one step, which + // is why a replay jumped from a bid straight past the seat who called it. + expect(n.round).toBe(1) + expect(n.pendingRoll).toBe(true) + }) + + it('opens the next round on the first action after a challenge, not on the challenge', () => { + const settled = base({ + dice: [[5, 5], [5, 1], [2, 2]], + bid: { count: 3, face: 5 }, + bidder: 0, + turn: 1, + round: 1, + }) + const revealed = game.reduce!(settled, { challenge: true }, ctxFor('B')) + const faces = revealed.dice.map((d) => [...d]) + + const opened = game.reduce!(revealed, { bid: { count: 1, face: 2 } }, ctxFor('B')) + expect(opened.round).toBe(2) + expect(opened.pendingRoll).toBe(false) + expect(opened.reveal).toBeNull() // the settled challenge is behind us now + // Re-rolled: same number of dice per seat, drawn again. + expect(opened.dice.map((d) => d.length)).toEqual(faces.map((d) => d.length)) + expect(opened.lastBid[1]).toEqual({ count: 1, face: 2 }) + expect(opened.lastBid[0]).toBeNull() // the ended round's bids are cleared + }) + + it('does not consume randomness when the action that would open the round is illegal', () => { + // A re-roll draws from ctx.random. Drawing it before a reject would advance + // the stream on an action that never happened, so replaying the same match + // would deal different dice from that point on. + const revealed = base({ + dice: [[5, 5], [5, 1], [2, 2]], + bid: null, + bidder: -1, + turn: 1, + round: 1, + pendingRoll: true, + reveal: { challenger: 1, bidder: 0, bid: { count: 3, face: 5 }, actual: 4, dice: [], loser: 1, eliminated: null }, + }) + let draws = 0 + const counting = (id: string) => ({ ...ctxFor(id), random: () => ((draws += 1), 0.5) }) + + expect(() => game.reduce!(revealed, { bid: { count: 99, face: 3 } }, counting('B'))).toThrow('impossible-bid') + expect(() => game.reduce!(revealed, { challenge: true }, counting('B'))).toThrow('nothing-to-challenge') + expect(draws).toBe(0) + + game.reduce!(revealed, { bid: { count: 1, face: 3 } }, counting('B')) + expect(draws).toBeGreaterThan(0) // a legal action does re-roll }) it('bid is a lie → the bidder loses a die', () => { diff --git a/games/liars-dice/view.ts b/games/liars-dice/view.ts index 59d70e7..c5ad6bf 100644 --- a/games/liars-dice/view.ts +++ b/games/liars-dice/view.ts @@ -16,7 +16,7 @@ * viewer's own cup mid-game. Identity (name/avatar) arrives via `onPlayers`; * author logic never sees it. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { hold, onFrame, onPlayers } from '@arena/game-sdk/view' import type { PlayerInfo } from '@arena/game-sdk' import { ARENA_THEME as T } from '@arena/game-sdk/theme' @@ -467,28 +467,18 @@ function draw(): void { // empties and restarts on the next arrival. const HOLD_MS = 2400 // minimum time a bid / turn frame stays on screen const REVEAL_MS = 6500 // the open-cup "LIAR?" reveal lingers so the result really lands -const frameQueue: DiceFrame[] = [] -let draining = false - -function drainNext(): void { - const frame = frameQueue.shift() - if (!frame) { - draining = false // queue empty → go idle; the next arrival restarts the drain - return - } - lastFrame = frame - draw() - setTimeout(drainNext, frame.reveal ? REVEAL_MS : HOLD_MS) -} onFrame((frame, h) => { ensureRoot(h) - frameQueue.push(frame as DiceFrame) - if (!draining) { - draining = true - drainNext() // draws this frame now, then paces the rest - } -}) + const f = frame as DiceFrame + lastFrame = f + draw() + // A reveal is the moment the whole game turns on, and it needs far longer than + // an ordinary bid. The SDK reports each frame as finished only when this + // resolves, so a host that waits gives the cups time to open instead of + // pushing the next bid over the top of them. + return hold(f.reveal ? REVEAL_MS : HOLD_MS) +}, { paceMs: HOLD_MS }) onPlayers((p) => { players = p draw() // identity applies immediately to the current frame diff --git a/games/othello/view.ts b/games/othello/view.ts index 4977b18..a0a06ce 100644 --- a/games/othello/view.ts +++ b/games/othello/view.ts @@ -19,7 +19,7 @@ * it in, so rendering `info.avatar` directly is safe. When a seat has no avatar * (or inlining failed) we fall back to a generated `data:` SVG monogram. */ -import { onFrame, onPlayers } from '@arena/game-sdk/view' +import { hold, onFrame, playbackSpeed, onPlayers } from '@arena/game-sdk/view' import type { PlayerInfo } from '@arena/game-sdk' // —— pacing knobs (ms) — tune these if replays still feel too fast/slow —— @@ -264,20 +264,18 @@ function lastMoveRing(ctx: CanvasRenderingContext2D, size: number, b: Board): vo ctx.stroke() } -// —— frame queue + animator —— -const queue: Frame[] = [] +// —— animator —— +// +// The queue and `busy` flag this used to keep are the SDK's job now: it draws one +// frame at a time and waits for the promise below, so a move can never be +// overdrawn by the next frame arriving, and the host is told when we are ready +// for another instead of having to guess an interval. let shown: Frame | null = null // last fully-rendered frame -let busy = false -function draw(frame: unknown, root: HTMLElement): void { +function draw(frame: unknown, root: HTMLElement): void | Promise { ensureDom(root) - queue.push(frame as Frame) - pump() -} - -function pump(): void { - if (busy || queue.length === 0 || !canvas) return - const next = queue.shift()! + if (!canvas) return + const next = frame as Frame const to = next.board const from = shown?.board const ctx = canvas.getContext('2d') @@ -294,16 +292,20 @@ function pump(): void { paint(ctx, canvas.width, to, to.palette ?? { 1: BLACK, 2: WHITE }, undefined) lastMoveRing(ctx, canvas.width, to) shown = next - pump() return } - animate(ctx, canvas.width, from, to, next) + return animate(ctx, canvas.width, from, to, next) } /** Animate the diff between two boards: new disc pops in, flipped discs turn over. */ -function animate(ctx: CanvasRenderingContext2D, size: number, from: Board, to: Board, toFrame: Frame): void { - busy = true +function animate( + ctx: CanvasRenderingContext2D, + size: number, + from: Board, + to: Board, + toFrame: Frame +): Promise { const N = to.cols const palette = to.palette ?? { 1: BLACK, 2: WHITE } const step = size / N @@ -331,40 +333,42 @@ function animate(ctx: CanvasRenderingContext2D, size: number, from: Board, to: B turnOverride = placed[0] ? placed[0].c - 1 : null renderHud() - const start = performance.now() - const frame = (now: number) => { - const t = Math.min(1, (now - start) / FLIP_MS) - // Static layer: everything that isn't animating. - paint(ctx, size, to, palette, skip) - // Placed discs grow in (ease-out). - const grow = 1 - Math.pow(1 - t, 3) - for (const p of placed) disc(ctx, at(p.x), at(p.y), r * grow, r * grow, palette[p.c] ?? '#888') - // Flipped discs turn over: width collapses to a line at the halfway point, - // colour swaps as it passes edge-on. - const w = Math.abs(Math.cos(Math.PI * t)) - for (const f of flipped) { - const col = t < 0.5 ? f.a : f.b - disc(ctx, at(f.x), at(f.y), r * w, r, palette[col] ?? '#888') - } - lastMoveRing(ctx, size, to) - - if (t < 1) { - requestAnimationFrame(frame) - } else { - // Move is fully on the board now — hand the highlight to whoever is next. - shown = toFrame - turnOverride = null - renderHud() - setTimeout(() => { - busy = false - pump() - }, HOLD_MS) + return new Promise((resolve) => { + const start = performance.now() + const frame = (now: number) => { + const t = Math.min(1, (now - start) / (FLIP_MS / playbackSpeed())) + // Static layer: everything that isn't animating. + paint(ctx, size, to, palette, skip) + // Placed discs grow in (ease-out). + const grow = 1 - Math.pow(1 - t, 3) + for (const p of placed) disc(ctx, at(p.x), at(p.y), r * grow, r * grow, palette[p.c] ?? '#888') + // Flipped discs turn over: width collapses to a line at the halfway point, + // colour swaps as it passes edge-on. + const w = Math.abs(Math.cos(Math.PI * t)) + for (const f of flipped) { + const col = t < 0.5 ? f.a : f.b + disc(ctx, at(f.x), at(f.y), r * w, r, palette[col] ?? '#888') + } + lastMoveRing(ctx, size, to) + + if (t < 1) { + requestAnimationFrame(frame) + } else { + // Move is fully on the board now — hand the highlight to whoever is next. + shown = toFrame + turnOverride = null + renderHud() + // The dwell is part of the frame, not something after it: resolving + // before it elapsed would tell the host we are ready while the move it + // just drew is still the thing being looked at. + void hold(HOLD_MS).then(resolve) + } } - } - requestAnimationFrame(frame) + requestAnimationFrame(frame) + }) } -onFrame(draw) +onFrame(draw, { paceMs: FLIP_MS + HOLD_MS }) // Identity can arrive before or after frames; refresh the HUD whenever it lands. onPlayers((p) => { players = p diff --git a/packages/game-sdk/src/view.ts b/packages/game-sdk/src/view.ts index 3858696..464d975 100644 --- a/packages/game-sdk/src/view.ts +++ b/packages/game-sdk/src/view.ts @@ -22,9 +22,49 @@ interface ViewMessage { __arenaView?: boolean type?: string frame?: unknown + /** Host-assigned frame token, echoed back on `frame-done`. See `onFrame`. */ + seq?: number + /** Playback rate the host is asking for. See `hold`. */ + speed?: number players?: PlayerInfo[] } +/** + * How fast the host wants playback to run: 1 = as authored, 2 = twice as fast. + * + * A host with a speed control has no way to make a view draw faster on its own — + * the timings live in here. Arena's feed learned that the hard way: unable to + * speed a view up, its 5x setting instead showed every fifth frame, so a card + * game skipped four plays out of five and the replay stopped making sense. + * + * A host that never sends a speed leaves this at 1, which is why the timings + * below read as the authored ones everywhere else. + */ +let speed = 1 + +/** Slowest and fastest a host may drive a view, so one bad value cannot freeze or strobe it. */ +const MIN_SPEED = 0.1 +const MAX_SPEED = 20 + +/** + * What a draw callback may return. + * + * Returning a promise is how a view says "this frame is not finished yet". The + * SDK will not draw the next frame, and will not tell the host it is ready for + * one, until the promise settles — so an animation is never cut off by the next + * frame landing on top of it. + */ +export type DrawResult = void | Promise + +/** + * Ceiling on how long the SDK waits for one frame's promise. + * + * A promise that never settles would otherwise wedge the view for good: no + * further frames drawn, no further acks sent, and nothing the host can do about + * it. Generous enough that no honest animation reaches it. + */ +const FRAME_TIMEOUT_MS = 15_000 + /** * Only the parent frame drives a view. * @@ -43,6 +83,25 @@ function fromHost(e: MessageEvent): boolean { return e.source === window.parent } +export interface FrameOptions { + /** + * Roughly how long this view spends on one frame, in ms. A HINT for the host's + * budgeting, not a promise — `frame-done` is the authority on when a frame is + * actually finished. + * + * A replay host has a fixed slot to fill (a feed card is a few tens of + * seconds) and has to decide HOW MUCH of a match fits before it draws + * anything. Without this it has to assume, and one assumption cannot fit every + * view: these range from a view with no animation at all to one that spends + * 6.5 seconds opening dice cups. Assuming the slow end throws away most of a + * fast view's match; assuming the fast end floods a slow one. + * + * Give the typical case, not the worst: a view whose frames are usually quick + * but occasionally long should say the quick number. + */ + paceMs?: number +} + /** * Register a draw callback. Called once per frame the platform posts in; also * signals readiness to the parent so it starts sending frames. @@ -51,21 +110,158 @@ function fromHost(e: MessageEvent): boolean { * import { onFrame } from '@arena/game-sdk/view' * onFrame((frame, root) => { root.innerHTML = ... }) * ``` + * + * ## Slow frames: return a promise + * + * Frames arrive when the HOST decides, which on a finished replay is as fast as + * the host feels like pushing them. If drawing a frame takes time — an + * animation, or a beat you want the viewer to actually register — return a + * promise and the SDK will hold everything until it settles: + * + * ```ts + * import { onFrame, hold } from '@arena/game-sdk/view' + * onFrame(async (frame, root) => { + * drawBoard(frame, root) + * await animateMove(frame) + * await hold(HOLD_MS) // let the finished move sit there + * }, { paceMs: HOLD_MS }) + * ``` + * + * Use `hold` rather than your own timer: it is what makes a host's speed control + * work, and a view that ignores it can only be sped up by dropping frames. + * + * The SDK then guarantees two things a view used to have to build for itself: + * + * 1. **Serialisation.** Frame N+1 is not drawn until frame N's promise settles. + * Every view that animates had hand-rolled a queue and a `busy` flag for + * this; that is now one implementation instead of one per game. + * 2. **Back-pressure.** `frame-done` goes to the host after each frame settles, + * so a host that waits for it sends the next frame exactly when this view is + * ready. Before, a host could only guess an interval — and a wrong guess is + * not a cosmetic problem: too fast built an unbounded backlog, so what was on + * screen drifted away from where the host believed playback was, pausing + * appeared to do nothing, and a match got cut off mid-animation when the card + * ended. + * + * A host is NOT required to wait for `frame-done` — the older ones do not know + * about it — so the queue still absorbs frames arriving faster than this view + * draws them. Nothing here breaks a view that draws synchronously and returns + * nothing: it simply acks immediately, which is the truth about that view. */ -export function onFrame(draw: (frame: unknown, root: HTMLElement) => void): void { +export function onFrame( + draw: (frame: unknown, root: HTMLElement) => DrawResult, + opts: FrameOptions = {} +): void { + const queue: Array<{ frame: unknown; seq: number | undefined }> = [] + let drawing = false + + /** + * Draw queued frames one at a time, acking each. + * + * The ack carries the host's own token back rather than a count of our own: + * a host that gave up waiting and moved on would otherwise be advanced twice + * by the late ack — once by its own timeout and again when we finally reply. + * Echoing the token lets it recognise and drop a reply it no longer wants. + */ + const pump = async (): Promise => { + if (drawing) return + drawing = true + try { + for (;;) { + const next = queue.shift() + pending = queue.length + if (!next) return + try { + const result = draw(next.frame, document.body) + if (result) await withTimeout(result) + } catch { + /* a draw error must never break the host, or stall the queue */ + } + window.parent.postMessage({ __arenaView: true, type: 'frame-done', seq: next.seq }, '*') + } + } finally { + drawing = false + } + } + window.addEventListener('message', (e: MessageEvent) => { if (!fromHost(e)) return const d = e.data as ViewMessage | null - if (d && d.__arenaView === true && d.type === 'frame') { - try { - draw(d.frame, document.body) - } catch { - /* a draw error must never break the host */ - } + if (!d || d.__arenaView !== true) return + if (d.type === 'frame') { + queue.push({ frame: d.frame, seq: d.seq }) + pending = queue.length + void pump() + } else if (d.type === 'speed' && typeof d.speed === 'number' && Number.isFinite(d.speed)) { + speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, d.speed)) } }) - // Only the parent talks to us; announce readiness. - window.parent.postMessage({ __arenaView: true, type: 'ready' }, '*') + // Only the parent talks to us; announce readiness, and how fast we draw. + window.parent.postMessage({ __arenaView: true, type: 'ready', paceMs: opts.paceMs }, '*') +} + +/** + * Wait `ms` of authored time, scaled by the speed the host asked for. + * + * This is the whole mechanism behind a host's speed control. `setTimeout` in a + * view is a fixed cost the host cannot influence; `hold` is the same pause + * expressed as something it can. At 2x a 1500ms dwell becomes 750ms, and the + * match plays twice as fast with every frame still shown. + * + * A speed change applies from the next `hold`, not retroactively to one already + * running — the wait in progress is at most one frame long, and cancelling it + * mid-animation would jump the board. + */ +export function hold(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms) / speed)) +} + +/** + * A pause that only applies when another frame is already waiting. + * + * `hold` is for time the frame NEEDS — an animation running, a beat the viewer has + * to register. `dwell` is for the padding that only exists to stop a burst of + * frames flashing past: when nothing is queued behind this frame, there is no + * burst to slow down, and waiting anyway just makes a live match lag behind + * reality and delays telling the host we are ready. + * + * Every view in this repo documented that intent ("a live match never fills the + * queue, so it stays responsive") and doudizhu was the one that implemented it, + * by only arming its timer when its queue was non-empty. Folding the queue into + * the SDK lost that, and the detail page — which pushes a frame every 1500ms — + * went from 1500ms a play to 1700ms, ending a measured 20 seconds one play + * behind where it used to be. This is that behaviour, named. + */ +export function dwell(ms: number): Promise { + return pending > 0 ? hold(ms) : Promise.resolve() +} + +/** Frames received but not yet drawn. Maintained by `onFrame`; read by `dwell`. */ +let pending = 0 + +/** + * The speed the host is asking for, for durations `hold` cannot express — a + * requestAnimationFrame tween needs to divide its own duration by this. + */ +export function playbackSpeed(): number { + return speed +} + +/** Resolve when `p` settles, or after `FRAME_TIMEOUT_MS` — whichever comes first. */ +function withTimeout(p: Promise): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, FRAME_TIMEOUT_MS) + void p.then( + () => { + clearTimeout(timer) + resolve() + }, + () => { + clearTimeout(timer) + resolve() + } + ) + }) } /** diff --git a/packages/game-sdk/test/view.test.ts b/packages/game-sdk/test/view.test.ts new file mode 100644 index 0000000..7703ae4 --- /dev/null +++ b/packages/game-sdk/test/view.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +/** + * `onFrame` is the boundary between a host and an author's renderer, and the + * properties tested here are the ones a host relies on to pace playback: frames + * are drawn one at a time, and `frame-done` means "this frame is finished", not + * "this frame arrived". + * + * Like `preview.test.ts`, this stubs the handful of globals the code touches + * rather than pulling in jsdom — nothing in this package needs a real DOM, and + * the stub makes it explicit which platform calls are part of the contract. + */ + +interface Posted { + __arenaView?: boolean + type?: string + seq?: number + paceMs?: number +} + +let posted: Posted[] +let handlers: Array<(e: MessageEvent) => void> +let parentWindow: object + +/** Deliver a message as the host would. */ +function fromHost(data: unknown): void { + for (const h of handlers) h({ source: parentWindow, data } as unknown as MessageEvent) +} + +function sendFrame(frame: unknown, seq?: number): void { + fromHost({ __arenaView: true, type: 'frame', frame, seq }) +} + +const acks = (): Array => + posted.filter((m) => m.type === 'frame-done').map((m) => m.seq) + +beforeEach(() => { + posted = [] + handlers = [] + parentWindow = { postMessage: (m: Posted) => posted.push(m) } + vi.stubGlobal('window', { + parent: parentWindow, + addEventListener: (type: string, h: (e: MessageEvent) => void) => { + if (type === 'message') handlers.push(h) + }, + }) + vi.stubGlobal('document', { body: { id: 'root' } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +/** Imported per-test: `onFrame` keeps per-registration queue state. */ +async function load() { + return (await import('../src/view.js')).onFrame +} + +describe('onFrame', () => { + it('announces readiness with the declared pace', async () => { + const onFrame = await load() + onFrame(() => {}, { paceMs: 1700 }) + expect(posted).toEqual([{ __arenaView: true, type: 'ready', paceMs: 1700 }]) + }) + + it('draws each frame and acks it with the host token', async () => { + const onFrame = await load() + const seen: unknown[] = [] + onFrame((frame) => { + seen.push(frame) + }) + + sendFrame('a', 1) + sendFrame('b', 2) + await Promise.resolve() + + expect(seen).toEqual(['a', 'b']) + // Echoed, not counted: a host that timed out on frame 1 and moved on has to + // be able to tell a late reply from the reply to what it is waiting for. + expect(acks()).toEqual([1, 2]) + }) + + it('hands the document body to the draw callback', async () => { + const onFrame = await load() + let root: unknown = null + onFrame((_frame, r) => { + root = r + }) + sendFrame('a') + await Promise.resolve() + expect(root).toEqual({ id: 'root' }) + }) + + it('does not draw the next frame until the current one settles', async () => { + const onFrame = await load() + const drawn: number[] = [] + let release: (() => void) | null = null + onFrame((frame) => { + drawn.push(frame as number) + return new Promise((resolve) => { + release = resolve + }) + }) + + sendFrame(1, 1) + sendFrame(2, 2) + sendFrame(3, 3) + await Promise.resolve() + + // This is the guarantee the whole protocol rests on: three frames arrived + // together and only one was drawn. Without it an animation is overdrawn by + // whatever lands next, which is what the host used to have to guess around. + expect(drawn).toEqual([1]) + expect(acks()).toEqual([]) + + release!() + await Promise.resolve() + await Promise.resolve() + expect(drawn).toEqual([1, 2]) + expect(acks()).toEqual([1]) + }) + + it('acks immediately when a draw returns nothing', async () => { + const onFrame = await load() + // A view with no animation is finished the moment it has drawn, and saying so + // is what lets a host fit more of a long match into a fixed slot. + onFrame(() => {}, { paceMs: 0 }) + sendFrame('a', 7) + await Promise.resolve() + expect(acks()).toEqual([7]) + }) + + it('acks a frame whose draw threw, rather than stalling the queue', async () => { + const onFrame = await load() + const drawn: number[] = [] + onFrame((frame) => { + drawn.push(frame as number) + if (frame === 1) throw new Error('author bug') + }) + + sendFrame(1, 1) + sendFrame(2, 2) + await Promise.resolve() + + // A host waiting on an ack that never comes stops playing entirely, so one + // bad frame must not be able to take the rest of the match with it. + expect(drawn).toEqual([1, 2]) + expect(acks()).toEqual([1, 2]) + }) + + it('gives up on a promise that never settles instead of wedging the view', async () => { + vi.useFakeTimers() + const onFrame = await load() + const drawn: number[] = [] + onFrame((frame) => { + drawn.push(frame as number) + return new Promise(() => {}) // never resolves + }) + + sendFrame(1, 1) + sendFrame(2, 2) + await vi.advanceTimersByTimeAsync(14_000) + expect(acks()).toEqual([]) + + await vi.advanceTimersByTimeAsync(2_000) + expect(acks()).toEqual([1]) + expect(drawn).toEqual([1, 2]) + }) + + it('scales a hold by the speed the host asks for', async () => { + vi.useFakeTimers() + const mod = await import('../src/view.js') + let done = false + mod.onFrame(async () => { + await mod.hold(2_000) + done = true + }) + + fromHost({ __arenaView: true, type: 'speed', speed: 4 }) + sendFrame('a', 1) + await vi.advanceTimersByTimeAsync(400) + expect(done).toBe(false) + + // 2000ms of authored time at 4x is 500ms of real time. Without this the only + // way a host could go faster would be to skip frames. + await vi.advanceTimersByTimeAsync(150) + expect(done).toBe(true) + expect(acks()).toEqual([1]) + expect(mod.playbackSpeed()).toBe(4) + }) + + it('clamps a speed a host should not be able to ask for', async () => { + const mod = await import('../src/view.js') + mod.onFrame(() => {}) + + fromHost({ __arenaView: true, type: 'speed', speed: 0 }) + expect(mod.playbackSpeed()).toBeGreaterThan(0) // 0 would stall every hold forever + fromHost({ __arenaView: true, type: 'speed', speed: 10_000 }) + expect(mod.playbackSpeed()).toBeLessThanOrEqual(20) // and this would strobe + fromHost({ __arenaView: true, type: 'speed', speed: Number.NaN }) + expect(Number.isFinite(mod.playbackSpeed())).toBe(true) + + fromHost({ __arenaView: true, type: 'speed', speed: 1 }) + expect(mod.playbackSpeed()).toBe(1) + }) + + it('leaves timings as authored for a host that never mentions speed', async () => { + vi.useFakeTimers() + const mod = await import('../src/view.js') + fromHost({ __arenaView: true, type: 'speed', speed: 1 }) + let done = false + mod.onFrame(async () => { + await mod.hold(1_500) + done = true + }) + sendFrame('a', 1) + await vi.advanceTimersByTimeAsync(1_400) + expect(done).toBe(false) + await vi.advanceTimersByTimeAsync(200) + expect(done).toBe(true) + }) + + it('dwells only when a frame is already waiting behind this one', async () => { + vi.useFakeTimers() + const mod = await import('../src/view.js') + fromHost({ __arenaView: true, type: 'speed', speed: 1 }) + // A 1000ms animation the frame genuinely needs, then 1700ms of padding that + // only matters when frames are stacking up. + mod.onFrame(async () => { + await mod.hold(1_000) + await mod.dwell(1_700) + }) + + // Nothing behind it: acked as soon as the animation is done. Waiting the + // extra 1700 anyway made a host that pushes on its own timer fall + // progressively behind — measured as a play lost every 20 seconds. + sendFrame('lone', 1) + await vi.advanceTimersByTimeAsync(1_100) + expect(acks()).toEqual([1]) + + // A frame landing mid-draw IS a backlog, so the padding applies and the + // burst is slowed to something watchable. + sendFrame('x', 2) + await vi.advanceTimersByTimeAsync(500) + sendFrame('y', 3) + await vi.advanceTimersByTimeAsync(600) // 'x' animation done, now dwelling + expect(acks()).toEqual([1]) + + await vi.advanceTimersByTimeAsync(1_800) + expect(acks()).toEqual([1, 2]) + }) + + it('ignores frames that did not come from the host', async () => { + const onFrame = await load() + const drawn: unknown[] = [] + onFrame((frame) => { + drawn.push(frame) + }) + + for (const h of handlers) { + h({ source: { other: true }, data: { __arenaView: true, type: 'frame', frame: 'x' } } as unknown as MessageEvent) + } + await Promise.resolve() + + // A view is author code running in a visitor's browser; "there happens to be + // no other frame right now" is not a property of this file. + expect(drawn).toEqual([]) + expect(acks()).toEqual([]) + }) + + it('ignores host messages that are not frames', async () => { + const onFrame = await load() + const drawn: unknown[] = [] + onFrame((frame) => { + drawn.push(frame) + }) + + fromHost({ __arenaView: true, type: 'players', players: [] }) + fromHost({ type: 'frame', frame: 'unmarked' }) + fromHost(null) + await Promise.resolve() + + expect(drawn).toEqual([]) + }) +}) diff --git a/spec/protocol.md b/spec/protocol.md index 24f8e5a..2f9ec0c 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -76,6 +76,36 @@ Author logic sees only opaque agent ids. The platform exposes live **identity** - Names/avatars are public, so identity is safe to expose even for `hiddenInfo` games (secrets still flow only through the per-viewer `render(state, {viewer})`). +### 5b. Frame pacing (T2 view ↔ host) + +A host decides when to push frames; on a finished replay that can be far faster +than a view draws them. Two messages let the view set the pace instead of the +host guessing it. + +| Direction | Message | Meaning | +|---|---|---| +| view → host | `{__arenaView, type:'ready', paceMs?}` | Ready for frames. `paceMs` is roughly how long one frame takes — a **hint** for budgeting, from `onFrame(draw, { paceMs })`. | +| host → view | `{__arenaView, type:'frame', frame, seq?}` | A frame. `seq` is an opaque host token. | +| view → host | `{__arenaView, type:'frame-done', seq}` | That frame is **finished** — animation played out, dwell elapsed. Echoes the host's `seq`. | + +A view returns a promise from `onFrame` to mark a frame unfinished; the SDK draws +one frame at a time, waits for it, then acks. So: + +- **A view MUST NOT** assume frames are spaced usefully — return a promise if + drawing takes time. +- **A host SHOULD** wait for `frame-done` before sending the next frame, and + **MUST** tolerate its absence (`paceMs` too) — views built against older SDKs + send neither. +- **A host MUST** ignore a `frame-done` whose `seq` is not the one it is waiting + for, or a reply arriving after it gave up will advance playback twice. + +Without this a host can only guess an interval, and one guess cannot fit every +view — these range from no animation at all to 6.5s opening a set of dice cups. +Guessing too fast builds an unbounded backlog, so what is on screen drifts away +from where the host believes playback is, pausing appears to do nothing, and the +match is cut off mid-animation when the slot ends; guessing too slow throws away +most of a fast view's match. + ## 6. Validation gates - **PR (this repo, `pnpm validate`)**: manifest ↔ meta agreement; source scan;