Build-out 6: the audience + global leaderboards

- The crowd: 11 seeded silhouette heads along the stage lip — idle sway,
  trembling past anger 0.5, a hard 110-frame duck at the string cut, happy
  bobbing on an S. Deterministic math in update(), harness-safe.
- Global boards: game/scores.ts client (silent-fail, 4s timeout) against the
  arcade server's new /api/sando/scores (via the new partly.party
  /arcade-api/ nginx proxy). Week score = sum of verdict totals across served
  days; KANSEI submits under a remembered chef name and shows rank + podium;
  the title shows top-5.
- E2E proven through https://partly.party/arcade-api/sando/scores (rank 1
  round-trip). Battery unchanged; crowd flinch + score accumulation verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 03:54:47 +10:00
parent 21dedad96a
commit 820b559556
5 changed files with 175 additions and 7 deletions

View File

@ -85,6 +85,29 @@
Attract ragdoll → the pulsing title. Verified: title screenshot, click path, Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
production `npm run build` clean. production `npm run build` clean.
## BUILD-OUT PASS 6 ✅ — the audience + the global board
- **The crowd**: 11 seeded silhouette heads (shoulders/head/occasional topknot)
along the stage lip. Idle sway; trembling once anger>0.5; a hard duck for 110
frames at the string cut (`Stage.flinch`); happy bobbing on an S
(`cheerCrowd()`). Pure deterministic math in update() — harness-safe.
- **Global leaderboards**: express endpoints on the arcade server
(`/api/sando/scores` GET?week=omote|ura / POST {name,score,week}, json-file
storage `sando-scores.json`, top-100 kept) + a new nginx location
**`/arcade-api/` → arcade-landing:3001/api/** in the partly.party server
block. Game client `game/scores.ts` (4s timeout, silent-fail — the game never
notices a dead server). Week score = Σ verdict.total across served days
(registry `sando.score`, reset on week start/toggle); KANSEI submits under a
remembered chef name (window.prompt once — ponytail: that IS the entry UI)
and shows rank + podium; the title shows the top-5 board.
- **Infra learned the hard way** (documented in arcade-landing repo +
`forum-nginx.conf` canonical copy): partly.party is served by the FIRST
server block in `/home/humanjing/forum/nginx/nginx.conf` (a bind-mount into
forum-nginx; `conf.d/` is NOT included — edits there are inert; docker cp
fails busy — edit the host file + `nginx -s reload`).
- E2E proven: POST via https://partly.party/arcade-api/sando/scores → rank 1 →
GET returns it. Battery unchanged; crowd flinches on cut; day-1 serve banks
7.63 into the week score.
## BUILD-OUT PASS 5 ✅ — the judge speaks, the band worries, the jelly wobbles ## BUILD-OUT PASS 5 ✅ — the judge speaks, the band worries, the jelly wobbles
- **Live barks**: every sin now earns a subtitled heckle at the foot of the stage - **Live barks**: every sin now earns a subtitled heckle at the foot of the stage
(`Stage.bark(group, seed)` → the existing line bank; seed varies the pick so (`Stage.bark(group, seed)` → the existing line bank; seed varies the pick so

View File

@ -92,6 +92,10 @@ export class Game {
this.state = 'served'; this.state = 'served';
this.lastVerdict = this.serve(true); this.lastVerdict = this.serve(true);
recordGrade(this.day + (this.ura ? 100 : 0), this.lastVerdict.grade); // ura bests live at 100+ recordGrade(this.day + (this.ura ? 100 : 0), this.lastVerdict.grade); // ura bests live at 100+
// the week's running score, headed for the leaderboard at KANSEI
const run = ((this.stage.registry.get('sando.score') as number) ?? 0) + this.lastVerdict.total;
this.stage.registry.set('sando.score', +run.toFixed(2));
if (this.lastVerdict.grade === 'S') this.stage.cheerCrowd();
this.stage.showScorecard(this.lastVerdict); this.stage.showScorecard(this.lastVerdict);
} }
} else { } else {

64
src/game/scores.ts Normal file
View File

@ -0,0 +1,64 @@
/**
* Global leaderboard client. Same-origin on partly.party via the /arcade-api/
* nginx proxy arcade-landing express. Every call is fire-and-forget with a
* short timeout and silent failure the game must never notice a dead server.
* (In local dev there's no proxy; fetches just fail quietly.)
*/
export interface ScoreRow {
name: string;
score: number;
date: string;
}
const API = '/arcade-api/sando/scores';
const TIMEOUT = 4000;
function withTimeout(ms: number): AbortSignal {
const c = new AbortController();
setTimeout(() => c.abort(), ms);
return c.signal;
}
export async function fetchTop(week: 'omote' | 'ura'): Promise<ScoreRow[]> {
try {
const r = await fetch(`${API}?week=${week}`, { signal: withTimeout(TIMEOUT) });
if (!r.ok) return [];
const rows = (await r.json()) as ScoreRow[];
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
export async function submitScore(name: string, score: number, week: 'omote' | 'ura'): Promise<number | null> {
try {
const r = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, score, week }),
signal: withTimeout(TIMEOUT),
});
if (!r.ok) return null;
const j = (await r.json()) as { rank?: number };
return j.rank ?? null;
} catch {
return null;
}
}
/** The chef's name, remembered once. ponytail: window.prompt is the whole name
* entry UI arcade-appropriate; build letter slots if anyone ever complains. */
export function chefName(): string | null {
try {
let n = localStorage.getItem('sandoniette.name');
if (!n) {
n = (window.prompt('Your chef name for the board? (3-12 letters)') ?? '').trim().slice(0, 12);
if (n.length < 3) return null;
localStorage.setItem('sandoniette.name', n);
}
return n;
} catch {
return null;
}
}

View File

@ -9,6 +9,7 @@ import type { Group, Verdict } from '../game/judging';
import { barkFor } from '../game/lines'; import { barkFor } from '../game/lines';
import { sfx, music } from '../game/audio'; import { sfx, music } from '../game/audio';
import { loadProgress, unlockUra } from '../game/progress'; import { loadProgress, unlockUra } from '../game/progress';
import { chefName, fetchTop, submitScore } from '../game/scores';
const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury']; const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'];
@ -58,6 +59,10 @@ export class Stage extends Phaser.Scene {
private barkText?: Phaser.GameObjects.Text; private barkText?: Phaser.GameObjects.Text;
private barkSub?: Phaser.GameObjects.Text; private barkSub?: Phaser.GameObjects.Text;
private wobbleParts = new Map<MatterJS.BodyType, { bodies: MatterJS.BodyType[]; cons: MatterJS.ConstraintType[] }>(); private wobbleParts = new Map<MatterJS.BodyType, { bodies: MatterJS.BodyType[]; cons: MatterJS.ConstraintType[] }>();
private crowd: { c: Phaser.GameObjects.Container; x: number; ph: number; sz: number }[] = [];
private crowdT = 0;
private flinch = 0; // frames of post-slash cowering left
private cheer = 0; // frames of S-grade bobbing left
constructor() { constructor() {
super('stage'); super('stage');
@ -148,6 +153,23 @@ export class Stage extends Phaser.Scene {
this.barkSub = this.add.text(STAGE_W / 2, 696, '', { fontFamily: 'serif', fontSize: '13px', color: '#9a8a6a', fontStyle: 'italic' }) this.barkSub = this.add.text(STAGE_W / 2, 696, '', { fontFamily: 'serif', fontSize: '13px', color: '#9a8a6a', fontStyle: 'italic' })
.setOrigin(0.5).setDepth(8).setAlpha(0); .setOrigin(0.5).setDepth(8).setAlpha(0);
// the audience — a row of little silhouette heads at the foot of the stage,
// watching from the dark like any good shadow theatre. They sway, they
// flinch when the blade comes out, they bob for an S. Seeded layout so the
// same crowd shows up every night.
this.crowd = [];
let cs = 77;
const crnd = () => ((cs = (cs * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
for (let i = 0; i < 11; i++) {
const x = 40 + i * (STAGE_W - 80) / 10 + (crnd() - 0.5) * 40;
const sz = 0.75 + crnd() * 0.5;
const c = this.add.container(x, STAGE_H + 6).setDepth(9);
c.add(this.add.ellipse(0, 0, 64 * sz, 46 * sz, 0x060403)); // shoulders
c.add(this.add.circle(0, -26 * sz, 17 * sz, 0x060403)); // head
if (crnd() < 0.35) c.add(this.add.rectangle(12 * sz, -40 * sz, 8 * sz, 14 * sz, 0x060403).setRotation(0.3)); // topknot
this.crowd.push({ c, x, ph: crnd() * Math.PI * 2, sz });
}
// ambient weather for the hour: dust motes at noon, petals at dusk, // ambient weather for the hour: dust motes at noon, petals at dusk,
// fireflies by night. A 5px generated dot is the whole particle budget. // fireflies by night. A 5px generated dot is the whole particle budget.
if (!this.textures.exists('mote')) { if (!this.textures.exists('mote')) {
@ -194,13 +216,9 @@ export class Stage extends Phaser.Scene {
// game for real (SHIN-KANSEI), then it's back to a quiet noon kitchen. // game for real (SHIN-KANSEI), then it's back to a quiet noon kitchen.
this.input.on('pointerdown', () => { this.input.on('pointerdown', () => {
if (this.kansei) { if (this.kansei) {
if (this.session.ura) { this.registry.set('sando.ura', !this.session.ura);
this.registry.set('sando.ura', false); this.registry.set('sando.day', 0);
this.registry.set('sando.day', 0); this.registry.set('sando.score', 0); // fresh week, fresh run
} else {
this.registry.set('sando.ura', true);
this.registry.set('sando.day', 0);
}
this.scene.restart(); this.scene.restart();
} else if (this.session.state === 'served') { } else if (this.session.state === 'served') {
if (this.session.day + 1 >= this.session.days.length) this.showKansei(); if (this.session.day + 1 >= this.session.days.length) this.showKansei();
@ -252,6 +270,22 @@ export class Stage extends Phaser.Scene {
fontFamily: 'serif', fontSize: '16px', color: ura ? '#9a8a6a' : '#e04a30', fontFamily: 'serif', fontSize: '16px', color: ura ? '#9a8a6a' : '#e04a30',
}).setOrigin(0.5), }).setOrigin(0.5),
); );
// the week's score → the global board (fire-and-forget; card updates when
// the server answers, and shows nothing if it never does)
const weekScore = (this.registry.get('sando.score') as number) ?? 0;
const week = ura ? 'ura' : 'omote';
g.add(this.add.text(cx, 305, `week score ${weekScore.toFixed(1)} / 80`, { fontFamily: 'monospace', fontSize: '15px', color: '#c8a24a' }).setOrigin(0.5));
const rankLine = this.add.text(cx, 328, '', { fontFamily: 'monospace', fontSize: '13px', color: '#9a8a6a' }).setOrigin(0.5);
g.add(rankLine);
const name = weekScore > 0 ? chefName() : null;
if (name) {
void submitScore(name, weekScore, week).then(async (rank) => {
const top = await fetchTop(week);
if (!g.active) return; // card already dismissed
const podium = top.slice(0, 3).map((r, i) => `${i + 1}. ${r.name} ${r.score.toFixed(1)}`).join(' ');
rankLine.setText(`${rank ? `board rank #${rank}` : ''}${podium ? ` · ${podium}` : ''}`);
});
}
this.scorecard = g; // rides the same clear path this.scorecard = g; // rides the same clear path
} }
@ -313,10 +347,42 @@ export class Stage extends Phaser.Scene {
} }
this.slashFx(); this.slashFx();
music.stop(); // the band stops when the blade comes out music.stop(); // the band stops when the blade comes out
this.flinch = 110; // the audience ducks
sfx.slash(); sfx.slash();
sfx.gong(); sfx.gong();
} }
/** An S-grade serve gets the little heads bobbing. */
cheerCrowd(): void {
this.cheer = 240;
}
/** The audience breathes every frame: idle sway, unease as the meter climbs,
* a hard duck at the slash, happy bobbing for an S. Pure deterministic math
* driven from update() so it works under the harness too. */
private updateCrowd(): void {
this.crowdT++;
const anger = this.session.samurai.anger;
if (this.flinch > 0) this.flinch--;
if (this.cheer > 0) this.cheer--;
for (let i = 0; i < this.crowd.length; i++) {
const m = this.crowd[i];
const sway = Math.sin(this.crowdT * 0.02 + m.ph) * 3;
const tremble = anger > 0.5 ? Math.sin(this.crowdT * 0.55 + m.ph * 3) * anger * 2.2 : 0;
let y = STAGE_H + 6;
let x = m.x + sway + tremble;
if (this.flinch > 0) {
// duck below the stage lip, then creep back up
const t = Math.min(1, this.flinch / 40);
y += 34 * t * m.sz;
x = m.x + Math.sin(this.crowdT * 0.8 + m.ph) * 1.2;
} else if (this.cheer > 0) {
y -= Math.abs(Math.sin(this.crowdT * 0.25 + m.ph)) * 10;
}
m.c.setPosition(x, y);
}
}
private slashFx(): void { private slashFx(): void {
const line = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W * 1.5, 7, 0xffffff).setDepth(20).setRotation(-0.5); const line = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W * 1.5, 7, 0xffffff).setDepth(20).setRotation(-0.5);
this.tweens.add({ targets: line, alpha: 0, duration: 450, onComplete: () => line.destroy() }); this.tweens.add({ targets: line, alpha: 0, duration: 450, onComplete: () => line.destroy() });
@ -568,6 +634,7 @@ export class Stage extends Phaser.Scene {
} }
this.drawStrings(); this.drawStrings();
this.drawServe(); this.drawServe();
this.updateCrowd();
this.refreshOrder(); this.refreshOrder();
this.updateHUD(); this.updateHUD();
} }

View File

@ -3,6 +3,7 @@ import { sfx, music } from '../game/audio';
import { STRING_STYLES, type StringStyle } from '../sim/marionette'; import { STRING_STYLES, type StringStyle } from '../sim/marionette';
import { loadProgress } from '../game/progress'; import { loadProgress } from '../game/progress';
import { DAYS } from '../game/game'; import { DAYS } from '../game/game';
import { fetchTop } from '../game/scores';
/** /**
* Title card, launched as an overlay over the live stage so the dev harness is * Title card, launched as an overlay over the live stage so the dev harness is
@ -70,6 +71,7 @@ export class Title extends Phaser.Scene {
const next = !((this.registry.get('sando.ura') as boolean) ?? false); const next = !((this.registry.get('sando.ura') as boolean) ?? false);
this.registry.set('sando.ura', next); this.registry.set('sando.ura', next);
this.registry.set('sando.day', 0); this.registry.set('sando.day', 0);
this.registry.set('sando.score', 0);
sfx.enable(); sfx.enable();
sfx.shamisen(); sfx.shamisen();
paintWeek(next); paintWeek(next);
@ -104,6 +106,14 @@ export class Title extends Phaser.Scene {
}); });
} }
// the global board, bottom-left — top 5 of the week (async; blank offline)
const board = this.add.text(28, H - 150, '', { fontFamily: 'monospace', fontSize: '12px', color: '#8a7a5a', lineSpacing: 5 })
.setDepth(2);
void fetchTop(((this.registry.get('sando.ura') as boolean) ?? false) ? 'ura' : 'omote').then((top) => {
if (!board.active || !top.length) return;
board.setText([' TOP CHEFS', ...top.slice(0, 5).map((r, i) => `${i + 1}. ${r.name.padEnd(13)} ${r.score.toFixed(1)}`)].join('\n'));
});
// begin: anywhere that isn't a style button. Restart the stage so the puppet // begin: anywhere that isn't a style button. Restart the stage so the puppet
// is strung with the chosen style, then drop the overlay. // is strung with the chosen style, then drop the overlay.
this.input.on('pointerdown', (_p: Phaser.Input.Pointer, over: unknown[]) => { this.input.on('pointerdown', (_p: Phaser.Input.Pointer, over: unknown[]) => {