Sfoglia il codice sorgente

Enforce hand state invariants

Tom McKenzie 1 mese fa
parent
commit
55a4a3c132
6 ha cambiato i file con 305 aggiunte e 88 eliminazioni
  1. 2 0
      package.json
  2. 29 21
      scripts/drive.sh
  3. 56 0
      src/domain/hand-validation.ts
  4. 115 54
      src/hand.ts
  5. 59 13
      src/index.ts
  6. 44 0
      tests/hand-validation.test.ts

+ 2 - 0
package.json

@@ -1,9 +1,11 @@
 {
   "name": "pm2000",
+  "type": "module",
   "scripts": {
     "dev": "pnpm build:client && wrangler dev",
     "build": "pnpm cf-typegen && pnpm build:client && tsc --noEmit",
     "build:client": "esbuild client/app.js --bundle --format=esm --target=es2022 --outfile=public/app.js",
+    "test": "node --test tests/*.test.ts",
     "deploy": "pnpm build && wrangler deploy --minify",
     "cf-typegen": "wrangler types",
     "auth:generate": "npx --yes auth@1.7.3 generate --config src/auth/index.ts --output src/db/auth.schema.ts -y",

+ 29 - 21
scripts/drive.sh

@@ -1,39 +1,47 @@
-set -e
-B=localhost:8787
-j(){ curl -s -b "$1" -c "$1" -H 'Content-Type: application/json' "${@:2}"; }
+#!/usr/bin/env bash
+set -euo pipefail
+
+B=${BASE_URL:-http://localhost:8787}
+COOKIE_DIR=$(mktemp -d)
+trap 'rm -rf "$COOKIE_DIR"' EXIT
+TOM="$COOKIE_DIR/tom"
+SAM="$COOKIE_DIR/sam"
+PHONE="$COOKIE_DIR/phone"
+
+j(){ curl --silent --show-error --fail-with-body -b "$1" -c "$1" -H 'Content-Type: application/json' "${@:2}"; }
 # two members: anonymous sign-in, set names
-j /tmp/tom -X POST $B/api/auth/sign-in/anonymous -d '{}' >/dev/null; j /tmp/tom -X POST $B/api/me/name -d '{"name":"Tom"}' >/dev/null
-j /tmp/sam -X POST $B/api/auth/sign-in/anonymous -d '{}' >/dev/null; j /tmp/sam -X POST $B/api/me/name -d '{"name":"Sam"}' >/dev/null
+j "$TOM" -X POST "$B/api/auth/sign-in/anonymous" -d '{}' >/dev/null; j "$TOM" -X POST "$B/api/me/name" -d '{"name":"Tom"}' >/dev/null
+j "$SAM" -X POST "$B/api/auth/sign-in/anonymous" -d '{}' >/dev/null; j "$SAM" -X POST "$B/api/me/name" -d '{"name":"Sam"}' >/dev/null
 # phone OTP path smoke test
-j /tmp/phone -X POST $B/api/auth/phone-number/send-otp -d '{"phoneNumber":"+61400000000"}'; echo " <- send-otp"
-H=$(j /tmp/tom -X POST $B/api/hands -d '{"question":"Friday dinner","options":["Thai","Pizza","Ramen","Cook at home"],"constraints":["Sam is trying to spend less this month"]}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
+j "$PHONE" -X POST "$B/api/auth/phone-number/send-otp" -d '{"phoneNumber":"+61400000000"}'; echo " <- send-otp"
+H=$(j "$TOM" -X POST "$B/api/hands" -d '{"question":"Friday dinner","options":["Thai","Pizza","Ramen","Cook at home"],"constraints":["Sam is trying to spend less this month"]}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
 echo "hand $H"
-A(){ j $1 -X POST $B/api/hands/$H/act -d "$2" >/dev/null; }
-A /tmp/tom '{"op":"join"}'; A /tmp/sam '{"op":"join"}'
-A /tmp/tom '{"op":"place","option":"o1","n":6}'; A /tmp/tom '{"op":"place","option":"o3","n":4}'
-A /tmp/sam '{"op":"place","option":"o2","n":8}'; A /tmp/sam '{"op":"place","option":"o4","n":2}'
-A /tmp/tom '{"op":"veto","option":"o2"}'          # Tom vetoes pizza; Sam's 8 chips come back
+A(){ j "$1" -X POST "$B/api/hands/$H/act" -d "$2" >/dev/null; }
+A "$TOM" '{"op":"join"}'; A "$SAM" '{"op":"join"}'
+A "$TOM" '{"op":"place","option":"o1","n":6}'; A "$TOM" '{"op":"place","option":"o3","n":4}'
+A "$SAM" '{"op":"place","option":"o2","n":8}'; A "$SAM" '{"op":"place","option":"o4","n":2}'
+A "$TOM" '{"op":"veto","option":"o2"}'          # Tom vetoes pizza; Sam's 8 chips come back
 sleep 1.2
-curl -s $B/api/hands/$H | python3 -c '
+curl --silent --show-error --fail-with-body "$B/api/hands/$H" | python3 -c '
 import sys,json; s=json.load(sys.stdin); r=s["round"]
 print("members",[m["name"] for m in s["members"]],"live",s["live"],"vetoed",r["vetoed"])
 print("chips",r["chips"])
 o=r["odds"]; print("odds", {k:round(v,2) for k,v in o["probabilities"].items()}, "conf",round(o["confidence"],2),"spice",round(o["spice"],2),"format",o["format"],"about",o["about"], o["ms"],"ms")'
-A /tmp/sam '{"op":"place","option":"o4","n":8}'   # Sam goes all in on cooking
+A "$SAM" '{"op":"place","option":"o4","n":8}'   # Sam goes all in on cooking
 sleep 0.6
-A /tmp/tom '{"op":"lock"}'; A /tmp/sam '{"op":"lock"}'   # both locked -> lever pulls
+A "$TOM" '{"op":"lock"}'; A "$SAM" '{"op":"lock"}'   # both locked -> lever pulls
 sleep 1.5
-curl -s $B/api/hands/$H | python3 -c '
+curl --silent --show-error --fail-with-body "$B/api/hands/$H" | python3 -c '
 import sys,json; s=json.load(sys.stdin); r=s["round"]; p=r["pull"]
 lab=lambda i: next(o["label"] for o in r["options"] if o["id"]==i)
 print("status",s["status"]); print("odds at pull",{lab(k):round(v,2) for k,v in p["odds"].items()})
 print("WINNER",lab(p["winner"]),"| runner-up",lab(p["runnerUp"]),"| reel order",[lab(x) for x in p["order"]],"| seed",p["seed"])
 print("dealer:",p["dealer"])'
-A /tmp/tom '{"op":"again"}'; sleep 0.3
-A /tmp/tom '{"op":"place","option":"o1","n":10}'; A /tmp/sam '{"op":"place","option":"o1","n":10}'; sleep 1
-A /tmp/tom '{"op":"pull"}'; sleep 1.5
-A /tmp/tom '{"op":"accept"}'; A /tmp/sam '{"op":"accept"}'
-curl -s $B/api/hands/$H | python3 -c '
+A "$TOM" '{"op":"again"}'; sleep 0.3
+A "$TOM" '{"op":"place","option":"o1","n":10}'; A "$SAM" '{"op":"place","option":"o1","n":10}'; sleep 1
+A "$TOM" '{"op":"lock"}'; A "$SAM" '{"op":"lock"}'; sleep 1.5
+A "$TOM" '{"op":"accept"}'; A "$SAM" '{"op":"accept"}'
+curl --silent --show-error --fail-with-body "$B/api/hands/$H" | python3 -c '
 import sys,json; s=json.load(sys.stdin); r=s["round"]; p=r["pull"]
 lab=lambda i: next(o["label"] for o in r["options"] if o["id"]==i)
 print("round",r["n"],"status",s["status"],"winner",lab(p["winner"]),{lab(k):round(v,2) for k,v in p["odds"].items()},"format",r["odds"]["format"]); print("dealer:",p["dealer"])'

+ 56 - 0
src/domain/hand-validation.ts

@@ -0,0 +1,56 @@
+export function normalizeText(value: unknown, maxLength: number): string | null {
+    if (typeof value !== "string") return null;
+    const text = value.trim().replace(/\s+/g, " ");
+    return text && text.length <= maxLength ? text : null;
+}
+
+export function normalizeUniqueTextList(value: unknown, maxItems: number, maxLength: number): string[] | null {
+    if (!Array.isArray(value) || value.length > maxItems) return null;
+    const items = value.map(item => normalizeText(item, maxLength));
+    if (items.some(item => item === null)) return null;
+    const result = items as string[];
+    return new Set(result.map(item => item.toLocaleLowerCase())).size === result.length ? result : null;
+}
+
+export function normalizeProbabilities(value: unknown, optionIds: string[]): Record<string, number> | null {
+    if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+    const input = value as Record<string, unknown>;
+    const weights: Record<string, number> = {};
+    for (const id of optionIds) {
+        const weight = input[id];
+        if (typeof weight !== "number" || !Number.isFinite(weight) || weight < 0) return null;
+        weights[id] = weight;
+    }
+    const total = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
+    if (!(total > 0)) return null;
+    for (const id of optionIds) weights[id] /= total;
+    return weights;
+}
+
+export function clampUnit(value: unknown, fallback = 0): number {
+    return typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
+}
+
+function mulberry32(seed: number) {
+    return () => {
+        seed |= 0;
+        seed = (seed + 0x6d2b79f5) | 0;
+        let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+        value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
+        return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
+    };
+}
+
+export function chooseWinner(optionIds: string[], weights: Record<string, number>, seed: number, confirmTop = false): string | null {
+    if (!optionIds.length) return null;
+    if (confirmTop) {
+        return optionIds.reduce((best, id) => (weights[id] > weights[best] ? id : best));
+    }
+    const total = optionIds.reduce((sum, id) => sum + weights[id], 0);
+    let draw = mulberry32(seed)() * total;
+    for (const id of optionIds) {
+        if (draw < weights[id]) return id;
+        draw -= weights[id];
+    }
+    return optionIds[optionIds.length - 1];
+}

+ 115 - 54
src/hand.ts

@@ -3,6 +3,7 @@
 import { DurableObject } from "cloudflare:workers";
 import { jev, type Choice } from "./jev";
 import { DEALER_LINES } from "./dealer";
+import { chooseWinner, clampUnit, normalizeProbabilities, normalizeText, normalizeUniqueTextList } from "./domain/hand-validation";
 import type { CloudflareBindings } from "./env";
 
 export type Member = { id: string; name: string; locked: boolean };
@@ -55,21 +56,23 @@ export type HandState = {
 };
 
 const MORE = "more_options";
+const TERMINAL = new Set<HandState["status"]>(["accepted", "expired"]);
 
-function mulberry32(seed: number) {
-    return () => {
-        seed |= 0;
-        seed = (seed + 0x6d2b79f5) | 0;
-        let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
-        t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
-        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
-    };
-}
+type Action = {
+    member: { id: string; name: string };
+    op: "join" | "place" | "veto" | "lock" | "pull" | "again" | "more" | "accept";
+    option?: string;
+    n?: number;
+    labels?: string[];
+};
 
 export class Hand extends DurableObject<CloudflareBindings> {
     private state: HandState | null = null;
     private oddsTimer: ReturnType<typeof setTimeout> | null = null;
     private oddsPending = false;
+    private oddsRevision = 0;
+    private oddsRequest = 0;
+    private pullInFlight: Promise<void> | null = null;
 
     private async load(): Promise<HandState | null> {
         if (!this.state) this.state = (await this.ctx.storage.get<HandState>("hand")) ?? null;
@@ -83,6 +86,9 @@ export class Hand extends DurableObject<CloudflareBindings> {
     async fetch(req: Request): Promise<Response> {
         const url = new URL(req.url);
         if (url.pathname.endsWith("/ws")) {
+            if (req.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
+                return Response.json({ error: "WebSocket upgrade required" }, { status: 426, headers: { Upgrade: "websocket" } });
+            }
             const pair = new WebSocketPair();
             this.ctx.acceptWebSocket(pair[1]);
             const s = await this.load();
@@ -90,14 +96,28 @@ export class Hand extends DurableObject<CloudflareBindings> {
             return new Response(null, { status: 101, webSocket: pair[0] });
         }
         if (url.pathname.endsWith("/init") && req.method === "POST") {
-            const body = (await req.json()) as { id: string; question: string; options: string[]; constraints?: string[] };
+            if (await this.load()) return Response.json({ error: "hand already exists" }, { status: 409 });
+            let body: { id?: unknown; question?: unknown; options?: unknown; constraints?: unknown };
+            try {
+                body = (await req.json()) as typeof body;
+            } catch {
+                return Response.json({ error: "invalid JSON body" }, { status: 400 });
+            }
+            const id = normalizeText(body.id, 64);
+            const question = normalizeText(body.question, 280);
+            const options = normalizeUniqueTextList(body.options, 4, 100);
+            const constraints = body.constraints === undefined ? [] : normalizeUniqueTextList(body.constraints, 8, 200);
+            if (!id || !question || !options || options.length < 2) {
+                return Response.json({ error: "invalid hand" }, { status: 400 });
+            }
+            if (!constraints) return Response.json({ error: "invalid constraints" }, { status: 400 });
             this.state = {
-                id: body.id,
-                question: body.question,
+                id,
+                question,
                 status: "open",
                 members: [],
-                rounds: [this.newRound(1, body.options.map((label, i) => ({ id: `o${i + 1}`, label })))],
-                constraints: body.constraints ?? [],
+                rounds: [this.newRound(1, options.map((label, i) => ({ id: `o${i + 1}`, label })))],
+                constraints,
                 accepts: [],
                 chipsPerMember: 10,
                 vetoesPerMember: 2,
@@ -111,11 +131,16 @@ export class Hand extends DurableObject<CloudflareBindings> {
             return s ? Response.json(this.publicState(s)) : new Response("not found", { status: 404 });
         }
         if (url.pathname.endsWith("/act") && req.method === "POST") {
-            const body = (await req.json()) as { member: { id: string; name: string }; op: string; option?: string; n?: number };
+            let body: Action;
+            try {
+                body = (await req.json()) as Action;
+            } catch {
+                return Response.json({ error: "invalid JSON body" }, { status: 400 });
+            }
             const s = await this.load();
             if (!s) return new Response("not found", { status: 404 });
             const err = await this.act(s, body);
-            if (err) return new Response(err, { status: 400 });
+            if (err) return Response.json({ error: err }, { status: 400 });
             return Response.json(this.publicState(s));
         }
         return new Response("no", { status: 404 });
@@ -140,31 +165,40 @@ export class Hand extends DurableObject<CloudflareBindings> {
     }
 
     // ---- actions ----
-    private async act(s: HandState, a: { member: { id: string; name: string }; op: string; option?: string; n?: number }): Promise<string | null> {
+    private async act(s: HandState, a: Action): Promise<string | null> {
+        const memberId = normalizeText(a?.member?.id, 128);
+        const memberName = normalizeText(a?.member?.name, 50);
+        if (!memberId || !memberName) return "invalid member";
+        if (TERMINAL.has(s.status)) return "hand is closed";
         const r = this.round(s);
-        const m = s.members.find(x => x.id === a.member.id);
+        const m = s.members.find(x => x.id === memberId);
+        if (a.op !== "join" && !m) return "join first";
         switch (a.op) {
             case "join": {
-                if (!m) s.members.push({ id: a.member.id, name: a.member.name, locked: false });
-                else m.name = a.member.name;
+                if (s.status !== "open" && s.status !== "posted") return "can't join now";
+                if (this.pullInFlight) return "the lever is already turning";
+                if (!m) s.members.push({ id: memberId, name: memberName, locked: false });
+                else m.name = memberName;
+                if (s.status === "open") this.scheduleOdds(true);
                 break;
             }
             case "place": {
                 if (!m || s.status !== "open" || m.locked) return "can't place";
                 const opt = this.liveOptions(r).find(o => o.id === a.option);
                 if (!opt) return "no such option";
-                const n = a.n ?? 1;
+                const n = a.n;
+                if (typeof n !== "number" || !Number.isInteger(n) || n === 0 || Math.abs(n) > s.chipsPerMember) return "invalid chip change";
                 const mine = (r.chips[m.id] ??= {});
                 const cur = mine[opt.id] ?? 0;
                 if (n > 0 && this.spent(r, m.id) + n > s.chipsPerMember) return "no chips left";
                 if (cur + n < 0) return "none there";
                 mine[opt.id] = cur + n;
                 if (mine[opt.id] === 0) delete mine[opt.id];
-                this.scheduleOdds();
+                this.scheduleOdds(true);
                 break;
             }
             case "veto": {
-                if (!m || s.status !== "open") return "can't veto";
+                if (!m || s.status !== "open" || m.locked) return "can't veto";
                 if (a.option === MORE) return "can't veto that";
                 if (this.vetoesUsed(s, m.id) >= s.vetoesPerMember) return "no vetoes left";
                 const live = this.liveOptions(r).filter(o => o.id !== MORE);
@@ -172,18 +206,19 @@ export class Hand extends DurableObject<CloudflareBindings> {
                 if (!live.find(o => o.id === a.option)) return "no such option";
                 r.vetoed.push({ by: m.id, option: a.option! });
                 for (const c of Object.values(r.chips)) delete c[a.option!]; // chips on it return to their owners
-                this.scheduleOdds();
+                this.scheduleOdds(true);
                 break;
             }
             case "lock": {
                 if (!m || s.status !== "open") return "can't lock";
                 m.locked = true;
-                if (s.members.every(x => x.locked)) await this.pull(s);
+                if (s.members.length > 0 && s.members.every(x => x.locked)) await this.ensurePull(s);
                 break;
             }
             case "pull": {
                 if (s.status !== "open") return "not open";
-                await this.pull(s);
+                if (!s.members.length || !s.members.every(member => member.locked)) return "everyone must lock first";
+                await this.ensurePull(s);
                 break;
             }
             case "again": {
@@ -193,21 +228,23 @@ export class Hand extends DurableObject<CloudflareBindings> {
                 s.members.forEach(x => (x.locked = false));
                 s.accepts = [];
                 s.status = "open";
-                this.scheduleOdds();
+                this.scheduleOdds(true);
                 break;
             }
             case "more": {
                 // Options agent hook: for now the client supplies new labels. Later: vision/LLM regen.
                 if (s.status !== "posted") return "nothing posted";
                 if (s.rounds.length >= 3) return "regen cap";
-                const labels = ((a as any).labels as string[] | undefined) ?? [];
+                const labels = a.labels ?? [];
+                const normalizedLabels = normalizeUniqueTextList(labels, 4, 100);
+                if (!normalizedLabels?.length) return "invalid labels";
                 const live = this.liveOptions(r).filter(o => o.id !== MORE);
-                const fresh = labels.map((label, i) => ({ id: `o${r.n + 1}_${i + 1}`, label }));
+                const fresh = normalizedLabels.map((label, i) => ({ id: `o${r.n + 1}_${i + 1}`, label }));
                 s.rounds.push(this.newRound(r.n + 1, [...live, ...fresh].slice(0, 4)));
                 s.members.forEach(x => (x.locked = false));
                 s.accepts = [];
                 s.status = "open";
-                this.scheduleOdds();
+                this.scheduleOdds(true);
                 break;
             }
             case "accept": {
@@ -225,7 +262,12 @@ export class Hand extends DurableObject<CloudflareBindings> {
     }
 
     // ---- Jev odds, debounced 200 ms ----
-    private scheduleOdds() {
+    private scheduleOdds(invalidate = false) {
+        if (invalidate) {
+            this.oddsRevision++;
+            const s = this.state;
+            if (s?.status === "open") this.round(s).odds = null;
+        }
         this.oddsPending = true;
         if (this.oddsTimer) return;
         this.oddsTimer = setTimeout(() => {
@@ -251,6 +293,8 @@ export class Hand extends DurableObject<CloudflareBindings> {
         const s = await this.load();
         if (!s || s.status !== "open") return;
         const r = this.round(s);
+        const revision = this.oddsRevision;
+        const request = ++this.oddsRequest;
         const f = this.features(s, r);
         const nameOf = (id: string) => s.members.find(m => m.id === id)?.name ?? id;
         const chipsByName: Record<string, Record<string, number>> = {};
@@ -267,7 +311,7 @@ export class Hand extends DurableObject<CloudflareBindings> {
             constraints: s.constraints,
             previous_rounds: s.rounds.slice(0, -1).map(pr => ({
                 n: pr.n,
-                posted: pr.pull ? r.options.find(x => x.id === pr.pull!.winner)?.label ?? pr.pull.winner : null,
+                posted: pr.pull ? pr.options.find(x => x.id === pr.pull!.winner)?.label ?? pr.pull.winner : null,
             })),
             live_option_count: f.liveCount,
             chip_agreement: f.agreement,
@@ -305,33 +349,58 @@ export class Hand extends DurableObject<CloudflareBindings> {
                 },
                 ...sandbag,
             } as any);
+            if (request !== this.oddsRequest || revision !== this.oddsRevision || s.status !== "open" || this.round(s) !== r) return;
             const a: any = res.answers;
+            const probabilities = normalizeProbabilities(a?.pick?.probabilities, f.live.map(option => option.id));
+            if (!probabilities) throw new Error("Jev returned invalid probabilities");
             const sandbagging: Record<string, number> = {};
-            for (const m of s.members) sandbagging[m.id] = a[`sb_${m.id}`]?.noul ?? 0;
+            for (const m of s.members) sandbagging[m.id] = clampUnit(a?.[`sb_${m.id}`]?.noul);
             r.odds = {
-                probabilities: a.pick.probabilities,
-                confidence: a.pick.confidence,
-                contentious: a.contentious.noul,
-                about: a.about.choice,
-                spice: a.spice.score,
-                format: a.format.choice,
+                probabilities,
+                confidence: clampUnit(a?.pick?.confidence),
+                contentious: clampUnit(a?.contentious?.noul),
+                about: normalizeText(a?.about?.choice, 40) ?? "none",
+                spice: typeof a?.spice?.score === "number" && Number.isFinite(a.spice.score) ? Math.min(3, Math.max(0, Math.round(a.spice.score))) : 0,
+                format: normalizeText(a?.format?.choice, 40) ?? "craps",
                 sandbagging,
-                ms: res.ms,
+                ms: typeof res.ms === "number" && Number.isFinite(res.ms) ? Math.max(0, res.ms) : 0,
                 at: Date.now(),
             };
             await this.save();
             this.broadcast({ type: "odds", odds: r.odds });
         } catch (e) {
+            if (request !== this.oddsRequest || revision !== this.oddsRevision || s.status !== "open" || this.round(s) !== r) return;
             this.broadcast({ type: "error", error: String(e) });
         }
     }
 
     // ---- the lever ----
+    private async ensurePull(s: HandState) {
+        if (this.pullInFlight) return this.pullInFlight;
+        const pull = this.pull(s);
+        this.pullInFlight = pull;
+        try {
+            await pull;
+        } finally {
+            if (this.pullInFlight === pull) this.pullInFlight = null;
+        }
+    }
+
+    private async refreshOdds() {
+        if (this.oddsTimer) {
+            clearTimeout(this.oddsTimer);
+            this.oddsTimer = null;
+        }
+        this.oddsPending = false;
+        await this.recomputeOdds();
+    }
+
     private async pull(s: HandState) {
         const r = this.round(s);
-        if (!r.odds || Date.now() - r.odds.at > 5000 || this.oddsPending) await this.recomputeOdds();
-        const p = r.odds?.probabilities;
+        if (!r.odds || Date.now() - r.odds.at > 5000 || this.oddsPending) await this.refreshOdds();
+        if (s.status !== "open" || this.round(s) !== r || !s.members.length || !s.members.every(member => member.locked)) return;
         const live = this.liveOptions(r);
+        const p = normalizeProbabilities(r.odds?.probabilities, live.map(option => option.id));
         // Fallback to chip proportions if Jev is unavailable
         const weights: Record<string, number> = {};
         if (p) for (const o of live) weights[o.id] = p[o.id] ?? 0;
@@ -340,24 +409,16 @@ export class Hand extends DurableObject<CloudflareBindings> {
             for (const o of live) weights[o.id] = (f.totals[o.id] ?? 0) + 0.01;
         }
         const total = Object.values(weights).reduce((a, b) => a + b, 0) || 1;
-        const seed = (Math.random() * 2 ** 32) >>> 0;
-        const rand = mulberry32(seed);
-        let u = rand() * total;
-        let winner = live[0].id;
-        for (const o of live) {
-            u -= weights[o.id];
-            if (u <= 0) {
-                winner = o.id;
-                break;
-            }
-        }
+        const seed = crypto.getRandomValues(new Uint32Array(1))[0];
+        const drawOptions = r.odds?.format === "one_tap" ? live.filter(option => option.id !== MORE) : live;
+        const winner = chooseWinner(drawOptions.map(option => option.id), weights, seed, r.odds?.format === "one_tap") ?? live[0].id;
         const ranked = [...live].sort((a, b) => weights[b.id] - weights[a.id]).map(o => o.id);
         const runnerUp = ranked.find(id => id !== winner) ?? winner;
         const rest = ranked.filter(id => id !== winner && id !== runnerUp);
         const order = [...rest, runnerUp, winner]; // near-miss: runner-up is always the last card before the winner
         const dealer = await this.dealerLine(s, r, winner, weights, total);
         r.pull = { seed, odds: Object.fromEntries(Object.entries(weights).map(([k, v]) => [k, v / total])), winner, runnerUp, order, dealer, at: Date.now() };
-        s.status = winner === MORE ? "posted" : "posted";
+        s.status = "posted";
         await this.save();
     }
 

+ 59 - 13
src/index.ts

@@ -1,11 +1,29 @@
 import { Hono } from "hono";
 import { createAuth } from "./auth";
+import { normalizeText, normalizeUniqueTextList } from "./domain/hand-validation";
 import type { CloudflareBindings } from "./env";
 export { Hand } from "./hand";
 
 type Variables = { auth: ReturnType<typeof createAuth>; me: { id: string; name: string } | null };
 const app = new Hono<{ Bindings: CloudflareBindings; Variables: Variables }>();
 
+const LIMITS = { name: 50, question: 280, option: 100, constraint: 200, constraints: 8 } as const;
+const HAND_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+async function jsonBody(c: any): Promise<Record<string, unknown> | null> {
+    try {
+        const value = await c.req.json();
+        return value && typeof value === "object" && !Array.isArray(value) ? value : null;
+    } catch {
+        return null;
+    }
+}
+
+function handStub(c: any) {
+    const id = c.req.param("id");
+    return HAND_ID.test(id) ? c.env.HAND.get(c.env.HAND.idFromName(id)) : null;
+}
+
 app.use("*", async (c, next) => {
     const auth = createAuth(c.env, (c.req.raw as any).cf || {}, new URL(c.req.url).origin);
     c.set("auth", auth);
@@ -19,8 +37,10 @@ app.all("/api/auth/*", c => c.get("auth").handler(c.req.raw));
 // Name is per-table display name; stored on the user for now.
 app.post("/api/me/name", async c => {
     const me = c.get("me");
-    if (!me) return c.text("sign in first", 401);
-    const { name } = (await c.req.json()) as { name: string };
+    if (!me) return c.json({ error: "sign in first" }, 401);
+    const body = await jsonBody(c);
+    const name = normalizeText(body?.name, LIMITS.name);
+    if (!name) return c.json({ error: `name must be 1–${LIMITS.name} characters` }, 400);
     await c.get("auth").api.updateUser({ headers: c.req.raw.headers, body: { name } });
     return c.json({ ok: true });
 });
@@ -28,30 +48,56 @@ app.post("/api/me/name", async c => {
 // Create a hand (draft -> open in one step for the prototype)
 app.post("/api/hands", async c => {
     const me = c.get("me");
-    if (!me) return c.text("sign in first", 401);
-    const body = (await c.req.json()) as { question: string; options: string[]; constraints?: string[] };
-    if (!body.question || !body.options || body.options.length < 2 || body.options.length > 4) return c.text("2–4 options", 400);
-    const id = crypto.randomUUID().slice(0, 8);
+    if (!me) return c.json({ error: "sign in first" }, 401);
+    const body = await jsonBody(c);
+    if (!body) return c.json({ error: "invalid JSON body" }, 400);
+    const question = normalizeText(body.question, LIMITS.question);
+    const options = normalizeUniqueTextList(body.options, 4, LIMITS.option);
+    const constraints = body.constraints === undefined ? [] : normalizeUniqueTextList(body.constraints, LIMITS.constraints, LIMITS.constraint);
+    if (!question) return c.json({ error: `question must be 1–${LIMITS.question} characters` }, 400);
+    if (!options || options.length < 2) return c.json({ error: "options must contain 2–4 unique labels" }, 400);
+    if (!constraints) return c.json({ error: `constraints must contain at most ${LIMITS.constraints} unique labels` }, 400);
+    const id = crypto.randomUUID();
     const stub = c.env.HAND.get(c.env.HAND.idFromName(id));
-    const res = await stub.fetch("https://hand/init", { method: "POST", body: JSON.stringify({ id, ...body }) });
-    return c.json(await res.json());
+    return stub.fetch("https://hand/init", { method: "POST", body: JSON.stringify({ id, question, options, constraints }) });
 });
 
 app.get("/api/hands/:id", async c => {
-    const stub = c.env.HAND.get(c.env.HAND.idFromName(c.req.param("id")));
+    const stub = handStub(c);
+    if (!stub) return c.json({ error: "invalid hand id" }, 400);
     return stub.fetch("https://hand/state");
 });
 
 app.post("/api/hands/:id/act", async c => {
     const me = c.get("me");
-    if (!me) return c.text("sign in first", 401);
-    const body = await c.req.json();
-    const stub = c.env.HAND.get(c.env.HAND.idFromName(c.req.param("id")));
+    if (!me) return c.json({ error: "sign in first" }, 401);
+    const body = await jsonBody(c);
+    if (!body) return c.json({ error: "invalid JSON body" }, 400);
+    if (typeof body.op !== "string" || !["join", "place", "veto", "lock", "pull", "again", "more", "accept"].includes(body.op)) {
+        return c.json({ error: "unknown operation" }, 400);
+    }
+    if ((body.op === "place" || body.op === "veto") && (typeof body.option !== "string" || body.option.length > 64)) {
+        return c.json({ error: "invalid option" }, 400);
+    }
+    if (body.op === "place" && (!Number.isInteger(body.n) || (body.n as number) === 0 || Math.abs(body.n as number) > 10)) {
+        return c.json({ error: "chip change must be a non-zero integer from -10 to 10" }, 400);
+    }
+    if (body.op === "more") {
+        const labels = normalizeUniqueTextList(body.labels, 4, LIMITS.option);
+        if (!labels?.length) return c.json({ error: "labels must contain 1–4 unique options" }, 400);
+        body.labels = labels;
+    }
+    const stub = handStub(c);
+    if (!stub) return c.json({ error: "invalid hand id" }, 400);
     return stub.fetch("https://hand/act", { method: "POST", body: JSON.stringify({ ...body, member: me }) });
 });
 
 app.get("/api/hands/:id/ws", async c => {
-    const stub = c.env.HAND.get(c.env.HAND.idFromName(c.req.param("id")));
+    if (c.req.header("Upgrade")?.toLowerCase() !== "websocket") {
+        return c.json({ error: "WebSocket upgrade required" }, 426, { Upgrade: "websocket" });
+    }
+    const stub = handStub(c);
+    if (!stub) return c.json({ error: "invalid hand id" }, 400);
     return stub.fetch("https://hand/ws", { headers: c.req.raw.headers });
 });
 

+ 44 - 0
tests/hand-validation.test.ts

@@ -0,0 +1,44 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { chooseWinner, clampUnit, normalizeProbabilities, normalizeText, normalizeUniqueTextList } from "../src/domain/hand-validation.ts";
+
+test("normalizeText trims and collapses whitespace", () => {
+    assert.equal(normalizeText("  Friday\n  dinner  ", 20), "Friday dinner");
+    assert.equal(normalizeText("   ", 20), null);
+    assert.equal(normalizeText("too long", 3), null);
+});
+
+test("normalizeUniqueTextList rejects duplicates and invalid bounds", () => {
+    assert.deepEqual(normalizeUniqueTextList([" Thai ", "Pizza"], 4, 20), ["Thai", "Pizza"]);
+    assert.equal(normalizeUniqueTextList(["Thai", "thai"], 4, 20), null);
+    assert.equal(normalizeUniqueTextList(["a", "b", "c"], 2, 20), null);
+    assert.equal(normalizeUniqueTextList(["valid", 2], 4, 20), null);
+});
+
+test("normalizeProbabilities accepts weights and normalizes only live options", () => {
+    assert.deepEqual(normalizeProbabilities({ a: 20, b: 30, stale: 50 }, ["a", "b"]), { a: 0.4, b: 0.6 });
+});
+
+test("normalizeProbabilities rejects malformed or unusable weights", () => {
+    assert.equal(normalizeProbabilities({ a: 1, b: -1 }, ["a", "b"]), null);
+    assert.equal(normalizeProbabilities({ a: 0, b: 0 }, ["a", "b"]), null);
+    assert.equal(normalizeProbabilities({ a: 1 }, ["a", "b"]), null);
+    assert.equal(normalizeProbabilities({ a: Number.NaN, b: 1 }, ["a", "b"]), null);
+});
+
+test("clampUnit makes Jev scalar metadata safe", () => {
+    assert.equal(clampUnit(-1), 0);
+    assert.equal(clampUnit(0.45), 0.45);
+    assert.equal(clampUnit(3), 1);
+    assert.equal(clampUnit("0.5"), 0);
+});
+
+test("one-tap winner confirms the highest weight with option-order tie breaking", () => {
+    assert.equal(chooseWinner(["first", "second"], { first: 0.2, second: 0.8 }, 123, true), "second");
+    assert.equal(chooseWinner(["first", "second"], { first: 0.5, second: 0.5 }, 123, true), "first");
+});
+
+test("seeded weighted winner is replayable", () => {
+    const weights = { first: 0.2, second: 0.8 };
+    assert.equal(chooseWinner(["first", "second"], weights, 42), chooseWinner(["first", "second"], weights, 42));
+});