Public: anyone can read this; the crew can change it.

The deal: a charter and four secret charges from a seed, in src/board.ts #24

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/parley:mason/6-the-deal-a-charter-and-four-secret-charg → main 2 commits fork

  • Merged. mason/6-the-deal-a-charter-and-four-secret-charg was merged into main as bf823aa.

Review

Sign in to review this change.
7 files changed +568 −11
AGENTS.md +6
@@ -33,6 +33,12 @@ door reads its wire format and shapes the answer; it decides nothing. A
3333 rule in an action holds at every door at once; a rule anywhere else is a
3434 bug waiting for the door you forgot.
3535
36 A rule of the game is not an action. `src/board.ts` is the deal and the
37 score from `RULES.md` — a seed and a seat count in, a charter and charges
38 out, no database, no clock, no `Math.random`, so it can be checked by hand
39 and tested against a fixture. Rules of that kind go in a pure module an
40 action calls; both speak the same named failures, from `src/result.ts`.
41
3642 Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`,
3743 verified by signature, never by a cookie the app invents. An action that
3844 needs a person checks `who.role`; ownership is a column (`author_id`) and
README.md +2
@@ -28,6 +28,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2828 | file | what it is |
2929 | ---------------------------------- | ------------------------------------------------------------ |
3030 | `src/actions.ts` | list, add, delete — and who may |
31 | `src/board.ts` | the deal and the score: a table from a seed, by RULES.md |
32 | `src/result.ts` | data or a named failure — the answer shape every door reads |
3133 | `src/who.ts` | who is asking, from the forge's signed headers |
3234 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
3335 | `src/live.ts` | the in-process bus and `GET /events` |
RULES.md +5
@@ -309,6 +309,11 @@ with its verdict.
309309 A four-seat table. Seed `tidewater`, seats Ash (0), Brail (1), Cleat (2),
310310 Dunnage (3). Quorum is ⌈4/2⌉+1 = **3**.
311311
312 The charter and charges below are **written by hand** so the arithmetic can
313 be checked by hand. They are what a deal looks like, not the literal output
314 of `deal("tidewater", 4)` — that table is recorded as a fixture in
315 `src/board.test.ts`, which also checks `score()` against the numbers here.
316
312317 **The charter** (7 of the 12):
313318
314319 | clause | settings |
src/actions.ts +3 −11
@@ -8,8 +8,11 @@
88
99 import type { Database } from "bun:sqlite";
1010 import { emit } from "./live.ts";
11 import { fail, type Failure, ok, type Result } from "./result.ts";
1112 import type { Who } from "./who.ts";
1213
14 export type { Failure, Result };
15
1316 export interface Note {
1417 readonly id: number;
1518 readonly body: string;
@@ -19,17 +22,6 @@ export interface Note {
1922 readonly updated_at: string;
2023 }
2124
22 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
23 export type Result<T> =
24 | { ok: true; value: T }
25 | { ok: false; error: Failure; message: string };
26 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
27 const fail = <T>(error: Failure, message: string): Result<T> => ({
28 ok: false,
29 error,
30 message,
31 });
32
3325 export const BODY_MAX = 500;
3426
3527 export function listNotes(
src/board.test.ts added +243
@@ -0,0 +1,243 @@
1 import { expect, test } from "bun:test";
2 import {
3 type Accord,
4 type Board,
5 type Charge,
6 CLAUSE_POOL,
7 type Clause,
8 deal,
9 dread,
10 positives,
11 score,
12 } from "./board.ts";
13
14 const dealt = (seed: string, players: number): Board => {
15 const r = deal(seed, players);
16 if (!r.ok) throw new Error(r.message);
17 return r.value;
18 };
19
20 /** A few dozen tables, every seat count, to test what must hold for all of them. */
21 function* tables(count = 40): Generator<Board> {
22 for (let i = 0; i < count; i++)
23 for (const players of [4, 5, 6]) yield dealt(`table-${i}`, players);
24 }
25
26 /** Every accord over a charter: 3^7 = 2187 of them, so tension can be brute-forced. */
27 function accords(clauses: Clause[]): Accord[] {
28 let all: Accord[] = [{}];
29 for (const clause of clauses)
30 all = all.flatMap((accord) =>
31 clause.settings.map((setting) => ({ ...accord, [clause.id]: setting })),
32 );
33 return all;
34 }
35
36 const fullPay = (charge: Charge): number =>
37 positives(charge).reduce((sum, aim) => sum + aim.weight, 0);
38
39 test("an empty seed and a seat count outside 4–6 are refused by name", () => {
40 for (const [seed, players] of [
41 ["", 4],
42 [" ", 4],
43 ["x".repeat(65), 4],
44 [undefined as unknown as string, 4],
45 ["tidewater", 3],
46 ["tidewater", 7],
47 ["tidewater", 4.5],
48 ["tidewater", Number.NaN],
49 ] as const) {
50 const r = deal(seed, players);
51 expect(r.ok).toBe(false);
52 if (!r.ok) expect(r.error).toBe("invalid");
53 }
54 expect(deal(" tidewater ", 4).ok).toBe(true);
55 for (const players of [4, 5, 6]) expect(deal("t", players).ok).toBe(true);
56 });
57
58 test("the charter is 7 clauses of the pool, each with exactly 3 settings", () => {
59 expect(CLAUSE_POOL.length).toBe(12);
60 expect(new Set(CLAUSE_POOL.map((c) => c.id)).size).toBe(12);
61 for (const clause of CLAUSE_POOL) {
62 expect(clause.settings.length).toBe(3);
63 expect(new Set(clause.settings).size).toBe(3);
64 }
65 for (const board of tables()) {
66 expect(board.clauses.length).toBe(7);
67 expect(new Set(board.clauses.map((c) => c.id)).size).toBe(7);
68 for (const clause of board.clauses)
69 expect(CLAUSE_POOL).toContainEqual(clause);
70 }
71 });
72
73 test("a charge is 4 aims on 4 distinct clauses: three distinct weights and a dread", () => {
74 for (const board of tables()) {
75 const onCharter = new Map(board.clauses.map((c) => [c.id, c]));
76 board.charges.forEach((charge, seat) => {
77 expect(charge.seat).toBe(seat);
78 expect(charge.aims.length).toBe(4);
79 expect(new Set(charge.aims.map((aim) => aim.clause)).size).toBe(4);
80 for (const aim of charge.aims) {
81 const clause = onCharter.get(aim.clause);
82 expect(clause).toBeDefined();
83 expect(clause!.settings).toContain(aim.setting);
84 }
85 const weights = positives(charge).map((aim) => aim.weight);
86 expect(weights.length).toBe(3);
87 expect(new Set(weights).size).toBe(3);
88 for (const weight of weights) expect([1, 2, 3, 5]).toContain(weight);
89 expect(dread(charge).weight).toBe(-3);
90 expect(fullPay(charge)).toBeGreaterThanOrEqual(6);
91 expect(fullPay(charge)).toBeLessThanOrEqual(10);
92 });
93 }
94 });
95
96 test("no two seats are dealt the same charge", () => {
97 const key = (charge: Charge) =>
98 charge.aims
99 .map((aim) => `${aim.clause}=${aim.setting}:${aim.weight}`)
100 .sort()
101 .join(" ");
102 for (const board of tables(200)) {
103 const keys = board.charges.map(key);
104 expect(new Set(keys).size).toBe(board.charges.length);
105 }
106 });
107
108 test("the deal is worth arguing over: seats collide and no accord pays everyone", () => {
109 for (const board of tables(12)) {
110 // At least two seats hold aims that cannot both be satisfied, and on at
111 // least two different clauses, so the table is not one clause of haggling.
112 const contested = new Set<string>();
113 for (const one of board.charges)
114 for (const other of board.charges)
115 for (const mine of positives(one))
116 for (const theirs of positives(other))
117 if (
118 one.seat !== other.seat &&
119 mine.clause === theirs.clause &&
120 mine.setting !== theirs.setting
121 )
122 contested.add(mine.clause);
123 expect(contested.size).toBeGreaterThanOrEqual(2);
124
125 // Brute force: of all 2187 accords, none satisfies every seat's positive
126 // aims, and none is dominant — every accord leaves a seat short.
127 const all = accords(board.clauses);
128 expect(all.length).toBe(2187);
129 for (const accord of all) {
130 const short = board.charges.filter(
131 (charge) => score(charge, accord) < fullPay(charge),
132 );
133 expect(short.length).toBeGreaterThan(0);
134 for (const charge of board.charges) {
135 expect(score(charge, accord)).toBeGreaterThanOrEqual(-3);
136 expect(score(charge, accord)).toBeLessThanOrEqual(10);
137 }
138 }
139 }
140 });
141
142 test("the same seed and count deal byte-identical tables", () => {
143 const render = (board: Board) =>
144 [
145 `charter ${board.clauses.map((c) => c.id).join(" ")}`,
146 ...board.charges.map(
147 (charge) =>
148 `seat ${charge.seat} ${charge.aims
149 .map((aim) => `${aim.clause}=${aim.setting}:${aim.weight}`)
150 .join(" ")}`,
151 ),
152 ].join("\n");
153
154 // Recorded fixture: what `deal("tidewater", 4)` has always dealt.
155 const TIDEWATER = [
156 "charter name salvage wreck hold pilot ledger sounding",
157 "seat 0 ledger=redacted:5 pilot=paid_off:1 wreck=stripped:2 salvage=split_evenly:-3",
158 "seat 1 hold=opened:2 ledger=published:3 pilot=reinstated:1 name=changed:-3",
159 "seat 2 sounding=lost:1 name=struck:2 pilot=reinstated:3 salvage=split_evenly:-3",
160 "seat 3 name=kept:2 wreck=left:5 ledger=burned:1 pilot=paid_off:-3",
161 ].join("\n");
162
163 expect(render(dealt("tidewater", 4))).toBe(TIDEWATER);
164 expect(JSON.stringify(dealt("tidewater", 4))).toBe(
165 JSON.stringify(dealt("tidewater", 4)),
166 );
167 expect(render(dealt(" tidewater ", 4))).toBe(TIDEWATER);
168 expect(render(dealt("tidewater", 5))).not.toBe(TIDEWATER);
169 expect(render(dealt("tidewate", 4))).not.toBe(TIDEWATER);
170 });
171
172 test("score pays the satisfied positives and docks the dread (RULES.md §10)", () => {
173 const aim = (pair: string, weight: number) => {
174 const [clause, setting] = pair.split("=") as [string, string];
175 return { clause, setting, weight };
176 };
177 const charge = (seat: number, aims: ReturnType<typeof aim>[]): Charge => ({
178 seat,
179 aims,
180 });
181 // The four charges of the worked example, written out by hand.
182 const ash = charge(0, [
183 aim("salvage=to_the_finders", 5),
184 aim("beacon=kept", 3),
185 aim("ledger=redacted", 1),
186 aim("pilot=put_ashore", -3),
187 ]);
188 const brail = charge(1, [
189 aim("ledger=published", 5),
190 aim("hold=opened", 2),
191 aim("name=changed", 1),
192 aim("salvage=to_the_crown", -3),
193 ]);
194 const cleat = charge(2, [
195 aim("pilot=reinstated", 3),
196 aim("chart=shared", 2),
197 aim("salvage=to_the_finders", 1),
198 aim("beacon=scuttled", -3),
199 ]);
200 const dunnage = charge(3, [
201 aim("beacon=kept", 5),
202 aim("hold=sealed", 3),
203 aim("name=kept", 2),
204 aim("ledger=redacted", -3),
205 ]);
206
207 const a1: Accord = {
208 beacon: "kept",
209 hold: "sealed",
210 ledger: "redacted",
211 pilot: "reinstated",
212 name: "kept",
213 chart: "locked",
214 salvage: "to_the_finders",
215 };
216 const a2: Accord = { ...a1, ledger: "published", name: "changed" };
217
218 // The accord of record binds every seat, signed or not.
219 expect([ash, brail, cleat, dunnage].map((c) => score(c, a2))).toEqual([
220 8, 6, 4, 8,
221 ]);
222 // A₁ pays Dunnage 5+3+2−3 = 7: his dread sits on `ledger=redacted`.
223 expect(score(dunnage, a1)).toBe(7);
224 expect(score(cleat, a1)).toBe(4);
225 // Nothing satisfied is nothing paid; a dread alone is −3.
226 const nothing: Accord = { ...a2, beacon: "sold", salvage: "split_evenly" };
227 expect(score(ash, nothing)).toBe(0);
228 expect(score(cleat, { ...nothing, beacon: "scuttled", pilot: "paid_off" })).toBe(
229 -3,
230 );
231 // Best possible: 2+3+5 with the dread untriggered.
232 expect(score(dunnage, { ...a1, name: "kept", ledger: "burned" })).toBe(10);
233 });
234
235 test("the deal reads no clock and no dice", async () => {
236 const source = await Bun.file(new URL("./board.ts", import.meta.url)).text();
237 // A boolean, not the file: a failure here should name the call, not dump 300 lines.
238 for (const forbidden of ["Math.random(", "Date.now(", "bun:sqlite"])
239 expect({ forbidden, present: source.includes(forbidden) }).toEqual({
240 forbidden,
241 present: false,
242 });
243 });
src/board.ts added +289
@@ -0,0 +1,289 @@
1 /**
2 * The deal, and what an accord pays (RULES.md §2, §3, §4).
3 *
4 * A table's charter and its secret charges are a function of the seed and
5 * the seat count and nothing else: plain data in, plain data out, no
6 * database, no clock, no `Math.random`. The same seed and the same count
7 * always give byte-identical output, so a table is reproducible, a bug is
8 * reportable as a seed, and every rule that reads a charge can be checked
9 * by hand.
10 */
11
12 import { fail, ok, type Result } from "./result.ts";
13
14 /** One question on the charter, with exactly 3 settings. */
15 export interface Clause {
16 readonly id: string;
17 readonly title: string;
18 readonly settings: readonly [string, string, string];
19 }
20
21 /** What a seat wants, and what it is worth: a positive weight, or the dread's −3. */
22 export interface Aim {
23 readonly clause: string;
24 readonly setting: string;
25 readonly weight: number;
26 }
27
28 /** A seat's secret hand: 4 aims on 4 distinct clauses, three positive and one dread. */
29 export interface Charge {
30 readonly seat: number;
31 readonly aims: readonly Aim[];
32 }
33
34 /** One setting for every clause of the charter — all 7, never a fragment. */
35 export type Accord = Readonly<Record<string, string>>;
36
37 export interface Board {
38 readonly clauses: Clause[];
39 readonly charges: Charge[];
40 }
41
42 // The fixed numbers (RULES.md §2), named once.
43 export const SEATS_MIN = 4;
44 export const SEATS_MAX = 6;
45 export const CHARTER_CLAUSES = 7;
46 export const AIMS_PER_CHARGE = 4;
47 export const POSITIVE_AIMS = 3;
48 export const POSITIVE_WEIGHTS = [1, 2, 3, 5] as const;
49 export const DREAD_WEIGHT = -3;
50 export const SEED_MAX = 64;
51
52 /** The pool of 12 (RULES.md §3). Flavour only: the mechanics are symmetric across every clause and setting. */
53 export const CLAUSE_POOL: readonly Clause[] = [
54 { id: "beacon", title: "The Beacon", settings: ["kept", "sold", "scuttled"] },
55 { id: "hold", title: "The Hold", settings: ["opened", "sealed", "flooded"] },
56 {
57 id: "ledger",
58 title: "The Ledger",
59 settings: ["published", "redacted", "burned"],
60 },
61 {
62 id: "pilot",
63 title: "The Pilot",
64 settings: ["reinstated", "paid_off", "put_ashore"],
65 },
66 { id: "name", title: "The Name", settings: ["kept", "changed", "struck"] },
67 { id: "chart", title: "The Chart", settings: ["shared", "sold", "locked"] },
68 {
69 id: "salvage",
70 title: "The Salvage",
71 settings: ["split_evenly", "to_the_finders", "to_the_crown"],
72 },
73 { id: "wreck", title: "The Wreck", settings: ["raised", "stripped", "left"] },
74 {
75 id: "crew",
76 title: "The Crew",
77 settings: ["signed_on", "paid_out", "turned_loose"],
78 },
79 {
80 id: "bond",
81 title: "The Bond",
82 settings: ["honoured", "renegotiated", "broken"],
83 },
84 {
85 id: "anchorage",
86 title: "The Anchorage",
87 settings: ["held", "leased", "abandoned"],
88 },
89 {
90 id: "sounding",
91 title: "The Sounding",
92 settings: ["filed", "amended", "lost"],
93 },
94 ];
95
96 /**
97 * Deal a table: 7 clauses of the pool, and one charge per seat.
98 *
99 * Refuses a seed that is not 1–64 characters or a seat count outside 4–6
100 * with a named failure — the boundary is here, because this is the only
101 * place a caller's seed becomes a game.
102 */
103 export function deal(seed: string, players: number): Result<Board> {
104 if (typeof seed !== "string")
105 return fail("invalid", "A seed is a line of text.");
106 const word = seed.trim();
107 if (word.length === 0 || word.length > SEED_MAX)
108 return fail("invalid", `A seed is 1 to ${SEED_MAX} characters.`);
109 if (!Number.isInteger(players) || players < SEATS_MIN || players > SEATS_MAX)
110 return fail("invalid", `A table seats ${SEATS_MIN} to ${SEATS_MAX}.`);
111
112 const rng = prng(`${word}:${players}`);
113 const clauses = shuffled(rng, CLAUSE_POOL).slice(0, CHARTER_CLAUSES);
114 const charges = drawCharges(rng, clauses, players);
115 assertWorthArguing(charges);
116 return ok({ clauses, charges });
117 }
118
119 /**
120 * What an accord pays a charge (RULES.md §4): the sum of the satisfied
121 * positive aims, less 3 when the dread's setting is the one chosen. A
122 * dread is a negative weight, so that is one sum.
123 */
124 export function score(charge: Charge, accord: Accord): number {
125 let total = 0;
126 for (const aim of charge.aims)
127 if (accord[aim.clause] === aim.setting) total += aim.weight;
128 return total;
129 }
130
131 /** The three aims a seat is paid for. */
132 export const positives = (charge: Charge): Aim[] =>
133 charge.aims.filter((aim) => aim.weight > 0);
134
135 /** The one aim a seat is paid to prevent. */
136 export const dread = (charge: Charge): Aim =>
137 charge.aims.find((aim) => aim.weight === DREAD_WEIGHT)!;
138
139 // ---------------------------------------------------------------- the deal
140
141 /**
142 * One charge per seat, then two forced disagreements: seat 1 is set against
143 * an earlier seat, and seat 2 against another on a different clause, so
144 * every table is argued over on at least two clauses and no accord can pay
145 * everybody in full. Charges that come out identical are redrawn.
146 */
147 function drawCharges(rng: Rng, clauses: Clause[], players: number): Charge[] {
148 const byId = new Map(clauses.map((clause) => [clause.id, clause]));
149 for (let attempt = 0; attempt < 32; attempt++) {
150 const hands = Array.from({ length: players }, () => drawHand(rng, clauses));
151 const contested: string[] = [];
152 for (const seat of [1, 2]) {
153 const against = rng.int(seat);
154 const clause = contest(rng, hands, seat, against, contested, byId);
155 if (clause !== null) contested.push(clause);
156 }
157 const charges = hands.map((aims, seat) => ({ seat, aims }));
158 if (contested.length === 2 && allDifferent(charges)) return charges;
159 }
160 // Unreachable: a redraw is only needed when two seats come out identical.
161 throw new Error("board: no table worth arguing over after 32 draws");
162 }
163
164 /** 4 aims on 4 distinct clauses: three distinct positive weights, then the dread. */
165 function drawHand(rng: Rng, clauses: Clause[]): Aim[] {
166 const drawn = shuffled(rng, clauses).slice(0, AIMS_PER_CHARGE);
167 const weights = shuffled(rng, POSITIVE_WEIGHTS).slice(0, POSITIVE_AIMS);
168 return drawn.map((clause, i) => ({
169 clause: clause.id,
170 setting: rng.of(clause.settings),
171 weight: i < POSITIVE_AIMS ? weights[i]! : DREAD_WEIGHT,
172 }));
173 }
174
175 /**
176 * Put `seat` in the way of `against`: a positive aim of theirs on a clause
177 * the other seat is paid for, at a different setting. Returns the clause
178 * they now disagree over, or null when there is none to take.
179 */
180 function contest(
181 rng: Rng,
182 hands: Aim[][],
183 seat: number,
184 against: number,
185 taken: string[],
186 byId: Map<string, Clause>,
187 ): string | null {
188 const mine = hands[seat]!;
189 const dreaded = mine.find((aim) => aim.weight === DREAD_WEIGHT)!.clause;
190 const wanted = shuffled(
191 rng,
192 hands[against]!.filter(
193 (aim) =>
194 aim.weight > 0 && !taken.includes(aim.clause) && aim.clause !== dreaded,
195 ),
196 );
197 const theirs = wanted[0];
198 if (theirs === undefined) return null;
199
200 const clause = byId.get(theirs.clause)!;
201 const otherwise = clause.settings.filter((s) => s !== theirs.setting);
202 const setting = rng.of(otherwise);
203 const held = mine.findIndex((aim) => aim.clause === theirs.clause);
204 const moved = held >= 0 ? held : rng.int(POSITIVE_AIMS);
205 mine[moved] = { clause: theirs.clause, setting, weight: mine[moved]!.weight };
206 return theirs.clause;
207 }
208
209 /** The three things RULES.md §3 says the code asserts about a deal. */
210 function assertWorthArguing(charges: Charge[]): void {
211 const clash = charges.some((one) =>
212 charges.some(
213 (other) =>
214 other.seat !== one.seat &&
215 positives(one).some((aim) =>
216 positives(other).some(
217 (mine) => mine.clause === aim.clause && mine.setting !== aim.setting,
218 ),
219 ),
220 ),
221 );
222 // Positive aims within a charge are on distinct clauses, so one clash is
223 // also the proof that no single accord satisfies every seat's positives.
224 if (!clash) throw new Error("board: dealt a table nobody would argue over");
225 if (!allDifferent(charges)) throw new Error("board: two seats, one charge");
226 for (const charge of charges) {
227 const weights = positives(charge).map((aim) => aim.weight);
228 const clauses = new Set(charge.aims.map((aim) => aim.clause));
229 if (
230 charge.aims.length !== AIMS_PER_CHARGE ||
231 clauses.size !== AIMS_PER_CHARGE ||
232 weights.length !== POSITIVE_AIMS ||
233 new Set(weights).size !== POSITIVE_AIMS
234 )
235 throw new Error(`board: seat ${charge.seat} is not a charge`);
236 }
237 }
238
239 const key = (charge: Charge): string =>
240 charge.aims
241 .map((aim) => `${aim.clause}=${aim.setting}:${aim.weight}`)
242 .sort()
243 .join(" ");
244
245 const allDifferent = (charges: Charge[]): boolean =>
246 new Set(charges.map(key)).size === charges.length;
247
248 // ------------------------------------------------------------ the randomness
249
250 interface Rng {
251 /** A whole number in [0, n). */
252 int(n: number): number;
253 /** One of the items. */
254 of<T>(items: readonly T[]): T;
255 }
256
257 /**
258 * A seeded pseudo-random number generator, written here so the deal owes
259 * nothing to a dependency or to the platform: FNV-1a over the seed for the
260 * state, mulberry32 for the stream, 32-bit integer arithmetic throughout,
261 * so the same seed gives the same sequence everywhere, forever.
262 */
263 function prng(seed: string): Rng {
264 let state = 0x811c9dc5;
265 for (let i = 0; i < seed.length; i++) {
266 state = (state ^ seed.charCodeAt(i)) >>> 0;
267 state = Math.imul(state, 0x01000193) >>> 0;
268 }
269 const next = (): number => {
270 state = (state + 0x6d2b79f5) >>> 0;
271 let t = Math.imul(state ^ (state >>> 15), 1 | state);
272 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
273 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
274 };
275 const int = (n: number): number => Math.floor(next() * n);
276 return { int, of: (items) => items[int(items.length)]! };
277 }
278
279 /** Fisher–Yates, from the seeded stream. */
280 function shuffled<T>(rng: Rng, items: readonly T[]): T[] {
281 const out = items.slice();
282 for (let i = out.length - 1; i > 0; i--) {
283 const j = rng.int(i + 1);
284 const swap = out[i]!;
285 out[i] = out[j]!;
286 out[j] = swap;
287 }
288 return out;
289 }
src/result.ts added +20
@@ -0,0 +1,20 @@
1 /**
2 * The answer shape every operation gives: data, or a failure with a name a
3 * door can turn into a status and a sentence. It lives on its own so pure
4 * modules (`src/board.ts`) speak the same vocabulary as the actions without
5 * dragging the database in behind them.
6 */
7
8 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
9
10 export type Result<T> =
11 | { ok: true; value: T }
12 | { ok: false; error: Failure; message: string };
13
14 export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
15
16 export const fail = <T>(error: Failure, message: string): Result<T> => ({
17 ok: false,
18 error,
19 message,
20 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs