|
@@ -3,6 +3,7 @@
|
|
|
import { DurableObject } from "cloudflare:workers";
|
|
import { DurableObject } from "cloudflare:workers";
|
|
|
import { jev, type Choice } from "./jev";
|
|
import { jev, type Choice } from "./jev";
|
|
|
import { DEALER_LINES } from "./dealer";
|
|
import { DEALER_LINES } from "./dealer";
|
|
|
|
|
+import { chooseWinner, clampUnit, normalizeProbabilities, normalizeText, normalizeUniqueTextList } from "./domain/hand-validation";
|
|
|
import type { CloudflareBindings } from "./env";
|
|
import type { CloudflareBindings } from "./env";
|
|
|
|
|
|
|
|
export type Member = { id: string; name: string; locked: boolean };
|
|
export type Member = { id: string; name: string; locked: boolean };
|
|
@@ -55,21 +56,23 @@ export type HandState = {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const MORE = "more_options";
|
|
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> {
|
|
export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
private state: HandState | null = null;
|
|
private state: HandState | null = null;
|
|
|
private oddsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private oddsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
private oddsPending = false;
|
|
private oddsPending = false;
|
|
|
|
|
+ private oddsRevision = 0;
|
|
|
|
|
+ private oddsRequest = 0;
|
|
|
|
|
+ private pullInFlight: Promise<void> | null = null;
|
|
|
|
|
|
|
|
private async load(): Promise<HandState | null> {
|
|
private async load(): Promise<HandState | null> {
|
|
|
if (!this.state) this.state = (await this.ctx.storage.get<HandState>("hand")) ?? 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> {
|
|
async fetch(req: Request): Promise<Response> {
|
|
|
const url = new URL(req.url);
|
|
const url = new URL(req.url);
|
|
|
if (url.pathname.endsWith("/ws")) {
|
|
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();
|
|
const pair = new WebSocketPair();
|
|
|
this.ctx.acceptWebSocket(pair[1]);
|
|
this.ctx.acceptWebSocket(pair[1]);
|
|
|
const s = await this.load();
|
|
const s = await this.load();
|
|
@@ -90,14 +96,28 @@ export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
|
}
|
|
}
|
|
|
if (url.pathname.endsWith("/init") && req.method === "POST") {
|
|
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 = {
|
|
this.state = {
|
|
|
- id: body.id,
|
|
|
|
|
- question: body.question,
|
|
|
|
|
|
|
+ id,
|
|
|
|
|
+ question,
|
|
|
status: "open",
|
|
status: "open",
|
|
|
members: [],
|
|
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: [],
|
|
accepts: [],
|
|
|
chipsPerMember: 10,
|
|
chipsPerMember: 10,
|
|
|
vetoesPerMember: 2,
|
|
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 });
|
|
return s ? Response.json(this.publicState(s)) : new Response("not found", { status: 404 });
|
|
|
}
|
|
}
|
|
|
if (url.pathname.endsWith("/act") && req.method === "POST") {
|
|
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();
|
|
const s = await this.load();
|
|
|
if (!s) return new Response("not found", { status: 404 });
|
|
if (!s) return new Response("not found", { status: 404 });
|
|
|
const err = await this.act(s, body);
|
|
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 Response.json(this.publicState(s));
|
|
|
}
|
|
}
|
|
|
return new Response("no", { status: 404 });
|
|
return new Response("no", { status: 404 });
|
|
@@ -140,31 +165,40 @@ export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- actions ----
|
|
// ---- 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 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) {
|
|
switch (a.op) {
|
|
|
case "join": {
|
|
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;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case "place": {
|
|
case "place": {
|
|
|
if (!m || s.status !== "open" || m.locked) return "can't place";
|
|
if (!m || s.status !== "open" || m.locked) return "can't place";
|
|
|
const opt = this.liveOptions(r).find(o => o.id === a.option);
|
|
const opt = this.liveOptions(r).find(o => o.id === a.option);
|
|
|
if (!opt) return "no such 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 mine = (r.chips[m.id] ??= {});
|
|
|
const cur = mine[opt.id] ?? 0;
|
|
const cur = mine[opt.id] ?? 0;
|
|
|
if (n > 0 && this.spent(r, m.id) + n > s.chipsPerMember) return "no chips left";
|
|
if (n > 0 && this.spent(r, m.id) + n > s.chipsPerMember) return "no chips left";
|
|
|
if (cur + n < 0) return "none there";
|
|
if (cur + n < 0) return "none there";
|
|
|
mine[opt.id] = cur + n;
|
|
mine[opt.id] = cur + n;
|
|
|
if (mine[opt.id] === 0) delete mine[opt.id];
|
|
if (mine[opt.id] === 0) delete mine[opt.id];
|
|
|
- this.scheduleOdds();
|
|
|
|
|
|
|
+ this.scheduleOdds(true);
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case "veto": {
|
|
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 (a.option === MORE) return "can't veto that";
|
|
|
if (this.vetoesUsed(s, m.id) >= s.vetoesPerMember) return "no vetoes left";
|
|
if (this.vetoesUsed(s, m.id) >= s.vetoesPerMember) return "no vetoes left";
|
|
|
const live = this.liveOptions(r).filter(o => o.id !== MORE);
|
|
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";
|
|
if (!live.find(o => o.id === a.option)) return "no such option";
|
|
|
r.vetoed.push({ by: m.id, option: a.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
|
|
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;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case "lock": {
|
|
case "lock": {
|
|
|
if (!m || s.status !== "open") return "can't lock";
|
|
if (!m || s.status !== "open") return "can't lock";
|
|
|
m.locked = true;
|
|
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;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case "pull": {
|
|
case "pull": {
|
|
|
if (s.status !== "open") return "not open";
|
|
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;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case "again": {
|
|
case "again": {
|
|
@@ -193,21 +228,23 @@ export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
s.members.forEach(x => (x.locked = false));
|
|
s.members.forEach(x => (x.locked = false));
|
|
|
s.accepts = [];
|
|
s.accepts = [];
|
|
|
s.status = "open";
|
|
s.status = "open";
|
|
|
- this.scheduleOdds();
|
|
|
|
|
|
|
+ this.scheduleOdds(true);
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case "more": {
|
|
case "more": {
|
|
|
// Options agent hook: for now the client supplies new labels. Later: vision/LLM regen.
|
|
// Options agent hook: for now the client supplies new labels. Later: vision/LLM regen.
|
|
|
if (s.status !== "posted") return "nothing posted";
|
|
if (s.status !== "posted") return "nothing posted";
|
|
|
if (s.rounds.length >= 3) return "regen cap";
|
|
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 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.rounds.push(this.newRound(r.n + 1, [...live, ...fresh].slice(0, 4)));
|
|
|
s.members.forEach(x => (x.locked = false));
|
|
s.members.forEach(x => (x.locked = false));
|
|
|
s.accepts = [];
|
|
s.accepts = [];
|
|
|
s.status = "open";
|
|
s.status = "open";
|
|
|
- this.scheduleOdds();
|
|
|
|
|
|
|
+ this.scheduleOdds(true);
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case "accept": {
|
|
case "accept": {
|
|
@@ -225,7 +262,12 @@ export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- Jev odds, debounced 200 ms ----
|
|
// ---- 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;
|
|
this.oddsPending = true;
|
|
|
if (this.oddsTimer) return;
|
|
if (this.oddsTimer) return;
|
|
|
this.oddsTimer = setTimeout(() => {
|
|
this.oddsTimer = setTimeout(() => {
|
|
@@ -251,6 +293,8 @@ export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
const s = await this.load();
|
|
const s = await this.load();
|
|
|
if (!s || s.status !== "open") return;
|
|
if (!s || s.status !== "open") return;
|
|
|
const r = this.round(s);
|
|
const r = this.round(s);
|
|
|
|
|
+ const revision = this.oddsRevision;
|
|
|
|
|
+ const request = ++this.oddsRequest;
|
|
|
const f = this.features(s, r);
|
|
const f = this.features(s, r);
|
|
|
const nameOf = (id: string) => s.members.find(m => m.id === id)?.name ?? id;
|
|
const nameOf = (id: string) => s.members.find(m => m.id === id)?.name ?? id;
|
|
|
const chipsByName: Record<string, Record<string, number>> = {};
|
|
const chipsByName: Record<string, Record<string, number>> = {};
|
|
@@ -267,7 +311,7 @@ export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
constraints: s.constraints,
|
|
constraints: s.constraints,
|
|
|
previous_rounds: s.rounds.slice(0, -1).map(pr => ({
|
|
previous_rounds: s.rounds.slice(0, -1).map(pr => ({
|
|
|
n: pr.n,
|
|
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,
|
|
live_option_count: f.liveCount,
|
|
|
chip_agreement: f.agreement,
|
|
chip_agreement: f.agreement,
|
|
@@ -305,33 +349,58 @@ export class Hand extends DurableObject<CloudflareBindings> {
|
|
|
},
|
|
},
|
|
|
...sandbag,
|
|
...sandbag,
|
|
|
} as any);
|
|
} as any);
|
|
|
|
|
+ if (request !== this.oddsRequest || revision !== this.oddsRevision || s.status !== "open" || this.round(s) !== r) return;
|
|
|
const a: any = res.answers;
|
|
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> = {};
|
|
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 = {
|
|
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,
|
|
sandbagging,
|
|
|
- ms: res.ms,
|
|
|
|
|
|
|
+ ms: typeof res.ms === "number" && Number.isFinite(res.ms) ? Math.max(0, res.ms) : 0,
|
|
|
at: Date.now(),
|
|
at: Date.now(),
|
|
|
};
|
|
};
|
|
|
await this.save();
|
|
await this.save();
|
|
|
this.broadcast({ type: "odds", odds: r.odds });
|
|
this.broadcast({ type: "odds", odds: r.odds });
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
|
|
+ if (request !== this.oddsRequest || revision !== this.oddsRevision || s.status !== "open" || this.round(s) !== r) return;
|
|
|
this.broadcast({ type: "error", error: String(e) });
|
|
this.broadcast({ type: "error", error: String(e) });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- the lever ----
|
|
// ---- 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) {
|
|
private async pull(s: HandState) {
|
|
|
const r = this.round(s);
|
|
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 live = this.liveOptions(r);
|
|
|
|
|
+ const p = normalizeProbabilities(r.odds?.probabilities, live.map(option => option.id));
|
|
|
// Fallback to chip proportions if Jev is unavailable
|
|
// Fallback to chip proportions if Jev is unavailable
|
|
|
const weights: Record<string, number> = {};
|
|
const weights: Record<string, number> = {};
|
|
|
if (p) for (const o of live) weights[o.id] = p[o.id] ?? 0;
|
|
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;
|
|
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 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 ranked = [...live].sort((a, b) => weights[b.id] - weights[a.id]).map(o => o.id);
|
|
|
const runnerUp = ranked.find(id => id !== winner) ?? winner;
|
|
const runnerUp = ranked.find(id => id !== winner) ?? winner;
|
|
|
const rest = ranked.filter(id => id !== winner && id !== runnerUp);
|
|
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 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);
|
|
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() };
|
|
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();
|
|
await this.save();
|
|
|
}
|
|
}
|
|
|
|
|
|