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

What you may do, and what it would pay you: `may`, `canStart` and the standing offer's worth, in the state itself #86

Merged opened this

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

Merged

mason/parley:mason/74-what-you-may-do-and-what-it-would-pay-yo → main 2 commits fork

  • Merged. mason/74-what-you-may-do-and-what-it-would-pay-yo was merged into main as 6ecafc0.

Review

Sign in to review this change.
4 files changed +303 −6
README.md +14 −1
@@ -130,6 +130,18 @@ and its acceptances are the `accept` acts after it. A new offer therefore
130130 clears every acceptance by existing, and there is no pointer or tally that
131131 can fall out of step with the record.
132132
133 Reading a table therefore answers the two questions a player has, once, for
134 every door: `may` is what *your* seat may do right now — offer, accept,
135 pass, the one sentence refusing each of them where it is refused, and
136 `cite`, the aims RULES.md §6 would let you swear on the standing offer, in
137 charter order — and `your_payoff` is what that offer would pay your seat if
138 it bound. Both are your own seat's and nobody else's: a guest, a watcher
139 and another player are handed `may: null`, `your_payoff: null` and no aim
140 of anyone's, and the page, the terminal and the MCP tools show a playable
141 turn without one of them re-deriving a rule. Neither is stored: `may` is
142 `src/round.ts` answering and `your_payoff` is `score()` in `src/board.ts`,
143 over acts already read.
144
133145 Because the turn is a count of that ledger, two doors of one seat could
134146 read the same count at the same instant and both believe they hold the
135147 turn. So the append is one immediate transaction that re-reads the acts and
@@ -193,7 +205,8 @@ An accord is one setting for every clause of *that* table's charter, which
193205 `GET /api/tables/1` gives you. Accepting costs a truth (RULES.md §6): the
194206 answer swears the first of your own aims the accord satisfies, or the one
195207 you name — `-d '{"cite":{"clause":"name","setting":"changed"}}'` — out of
196 those it lets you swear.
208 those it lets you swear, which are the ones `may.cite` in that same state
209 lists for you.
197210
198211 A refusal answers with its name and the status that matches it:
199212 `signin_required` 401, `forbidden` 403 (no seat of yours here),
src/actions.test.ts +186 −1
@@ -7,6 +7,7 @@ import {
77 acceptOffer,
88 canJoin,
99 canLeave,
10 canStart,
1011 joinTable,
1112 leaveTable,
1213 listTables,
@@ -19,8 +20,18 @@ import {
1920 TITLE_MAX,
2021 type TableView,
2122 } from "./actions.ts";
23 import { type Charge, score } from "./board.ts";
2224 import { openDb } from "./db.ts";
23 import { a1, a2, CHARTER, charges } from "./example.ts";
25 import {
26 a1,
27 a2,
28 ash,
29 brail,
30 CHARTER,
31 charges,
32 cleat,
33 dunnage,
34 } from "./example.ts";
2435 import { subscribe } from "./live.ts";
2536 import type { Who } from "./who.ts";
2637
@@ -429,6 +440,29 @@ test("an admin may start a table that is not theirs", () => {
429440 expect(startTable(db, root, { id: table.id }).ok).toBe(true);
430441 });
431442
443 test("the button to deal and `startTable` refuse exactly the same tables", () => {
444 const table = filled(alice, [bob, carol], 6);
445 // Three seats is not a table yet: the host is refused, and so is the
446 // button that would have asked.
447 expect(canStart(alice, at(table.id, alice))).toBe(false);
448 expect(startTable(db, alice, { id: table.id }).ok).toBe(false);
449
450 expect(joinTable(db, dave, { id: table.id }).ok).toBe(true);
451 expect(canStart(alice, at(table.id, alice))).toBe(true);
452 // It is the host's act, or an admin's: a guest and another player are
453 // shown no button and refused the same way if they ask anyway.
454 expect(canStart(anon, at(table.id, anon))).toBe(false);
455 expect(canStart(bob, at(table.id, bob))).toBe(false);
456 expect(startTable(db, bob, { id: table.id }).ok).toBe(false);
457 expect(canStart(root, at(table.id, root))).toBe(true);
458
459 expect(startTable(db, alice, { id: table.id }).ok).toBe(true);
460 // Dealt once: a table being played has nothing left to start.
461 for (const who of [alice, root])
462 expect(canStart(who, at(table.id, who))).toBe(false);
463 expect(startTable(db, alice, { id: table.id }).ok).toBe(false);
464 });
465
432466 test("the seats a table starts with are renumbered 0…n−1, gaps closed", () => {
433467 // People came and went: five seats were taken, two were given up, and one
434468 // more was taken after them, so the order on the table is 0, 2, 4, 5.
@@ -745,6 +779,157 @@ test("a citation is the seat's choice among what §6 allows, and nothing else",
745779 });
746780 });
747781
782 test("the state says what this seat may do, in §5 and §6's own words", () => {
783 const id = table10();
784 const seats = order(id);
785
786 // The seat to act may offer and may pass. There is nothing to accept
787 // before the first offer, so there is nothing it could swear either.
788 const ash0 = at(id, alice).may!;
789 expect(ash0.seat).toBe(seats[0]!);
790 expect(ash0.turn).toBe(true);
791 expect(ash0.offer).toEqual({ may: true, refusal: null });
792 expect(ash0.pass).toEqual({ may: true, refusal: null });
793 expect(ash0.accept).toEqual({
794 may: false,
795 refusal: "There is nothing to accept yet.",
796 });
797 expect(ash0.cite).toEqual([]);
798
799 // A seat waiting its turn is given the refusal it would get, for each of
800 // the three acts, in one sentence.
801 const waiting = at(id, bob).may!;
802 expect(waiting.turn).toBe(false);
803 expect([waiting.offer, waiting.accept, waiting.pass]).toEqual(
804 Array.from({ length: 3 }, () => ({
805 may: false,
806 refusal: "It is not your turn.",
807 })),
808 );
809
810 // 1. Ash offers A₁, which pays Brail's charge nothing: §6 gives him the
811 // refusal and no citation at all, and the action says the same words.
812 expect(makeOffer(db, alice, { id, accord: a1 }).ok).toBe(true);
813 const nothing = at(id, bob).may!;
814 expect(nothing.turn).toBe(true);
815 expect(nothing.offer.may).toBe(true);
816 expect(nothing.accept).toEqual({
817 may: false,
818 refusal: "This accord satisfies none of your aims.",
819 });
820 expect(nothing.cite).toEqual([]);
821 const refused = acceptOffer(db, bob, { id });
822 expect(refused.ok).toBe(false);
823 if (!refused.ok) expect(refused.message).toBe(nothing.accept.refusal);
824
825 // 2. Cleat may accept, and `cite` is the two truths §6 lets him swear —
826 // his own aims, weights and all, in charter order.
827 expect(passTurn(db, bob, { id }).ok).toBe(true);
828 const may = at(id, carol).may!;
829 expect(may.accept).toEqual({ may: true, refusal: null });
830 expect(may.cite).toEqual([
831 { clause: "pilot", setting: "reinstated", weight: 3 },
832 { clause: "salvage", setting: "to_the_finders", weight: 1 },
833 ]);
834
835 // 3. He swears the first of them; coming round to the same offer again,
836 // the refusal is the other one §6 has.
837 expect(acceptOffer(db, carol, { id }).ok).toBe(true);
838 for (const player of [dave, alice, bob])
839 expect(passTurn(db, player, { id }).ok).toBe(true);
840 const twice = at(id, carol).may!;
841 expect(twice.turn).toBe(true);
842 expect(twice.pass.may).toBe(true);
843 expect(twice.accept).toEqual({
844 may: false,
845 refusal: "You have already accepted the standing offer.",
846 });
847 expect(twice.cite).toEqual([]);
848 const again = acceptOffer(db, carol, { id });
849 expect(again.ok).toBe(false);
850 if (!again.ok) expect(again.message).toBe(twice.accept.refusal);
851 });
852
853 test("`may` is the viewer's own seat's, and `cite` nobody else's aims", () => {
854 const id = table10();
855 expect(makeOffer(db, alice, { id, accord: a1 }).ok).toBe(true);
856
857 // A guest, a stranger and an admin with no seat are told nothing about
858 // any seat's turn: there is no seat of theirs to have one.
859 for (const viewer of [anon, erin, root]) {
860 const state = at(id, viewer);
861 expect(state.may).toBeNull();
862 expect(state.your_payoff).toBeNull();
863 expect(state.seats.every((seat) => seat.charge === null)).toBe(true);
864 }
865
866 // A player is told about their own seat and no other, and every aim they
867 // are offered to swear is an aim of their own charge — `cite` carries
868 // weights, so a citable aim of somebody else's would be a leaked charge.
869 for (const [who, name] of [
870 [alice, "alice"],
871 [bob, "bob"],
872 [carol, "carol"],
873 [dave, "dave"],
874 ] as const) {
875 const state = at(id, who);
876 const mine = seatOf(state, name).charge as Charge;
877 expect(state.may!.seat).toBe(state.your_seat!);
878 expect(state.may!.turn).toBe(state.your_turn);
879 for (const aim of state.may!.cite) expect(mine.aims).toContainEqual(aim);
880 // And nobody else's charge came with it.
881 expect(
882 state.seats.filter((seat) => seat.charge !== null).map((seat) => seat.id),
883 ).toEqual([state.your_seat!]);
884 }
885 });
886
887 test("`your_payoff` is what the standing accord would pay this seat", () => {
888 const id = table10();
889 // Nothing stands, so there is nothing on the table to be paid.
890 expect(at(id, alice).your_payoff).toBeNull();
891
892 // RULES.md §10, by hand: A₁ pays Ash 5+3+1, Brail nothing, Cleat 3+1 and
893 // Dunnage 5+3+2−3 = 7, his dread being the Ledger redacted.
894 expect(makeOffer(db, alice, { id, accord: a1 }).ok).toBe(true);
895 expect([alice, bob, carol, dave].map((who) => at(id, who).your_payoff)).toEqual(
896 [9, 0, 4, 7],
897 );
898 // Which is `score` in src/board.ts, carried through rather than redone.
899 expect([ash, brail, cleat, dunnage].map((held) => score(held, a1))).toEqual([
900 9, 0, 4, 7,
901 ]);
902
903 // A new offer is a new sum: A₂ buys Brail his Ledger and costs Ash the +1
904 // she had for it redacted.
905 for (const player of [bob, carol, dave])
906 expect(passTurn(db, player, { id }).ok).toBe(true);
907 expect(makeOffer(db, alice, { id, accord: a2 }).ok).toBe(true);
908 expect([alice, bob, carol, dave].map((who) => at(id, who).your_payoff)).toEqual(
909 [8, 6, 4, 8],
910 );
911 expect([ash, brail, cleat, dunnage].map((held) => score(held, a2))).toEqual([
912 8, 6, 4, 8,
913 ]);
914 // It is the viewer's own seat's arithmetic and nobody else's.
915 expect(at(id, anon).your_payoff).toBeNull();
916 expect(at(id, erin).your_payoff).toBeNull();
917 });
918
919 test("a table nobody has dealt has no turn for anyone to take", () => {
920 const table = filled(alice, [bob, carol, dave]);
921 // An open table has an order but no charge, no offer and no turn: there
922 // is nothing §6 could answer, so there is nothing to answer with.
923 for (const who of [alice, bob, anon]) {
924 const state = at(table.id, who);
925 expect(state.may).toBeNull();
926 expect(state.your_payoff).toBeNull();
927 }
928 expect(startTable(db, alice, { id: table.id }).ok).toBe(true);
929 expect(at(table.id, alice).may!.offer.may).toBe(true);
930 expect(at(table.id, bob).may!.turn).toBe(false);
931 });
932
748933 /**
749934 * One door, in its own process, taking one turn per tick of a clock every
750935 * other door is watching. `bun:sqlite` is synchronous, so two promises are
src/actions.ts +79 −4
@@ -24,6 +24,7 @@ import {
2424 type Cite,
2525 type Clause,
2626 deal,
27 score,
2728 validAccord,
2829 } from "./board.ts";
2930 import { allow } from "./http.ts";
@@ -138,6 +139,16 @@ export interface Standing {
138139 readonly acceptances: readonly number[];
139140 }
140141
142 /**
143 * What the viewer's own seat may do right now (§5, §6), in the vocabulary a
144 * door speaks: `src/round.ts`'s `MayAct` carried through, with the seat
145 * named by its id rather than by the ordinal the rules count in.
146 */
147 export interface May extends Omit<MayAct, "seat"> {
148 /** The viewer's own seat, by seat id — the same id `your_seat` carries. */
149 readonly seat: number;
150 }
151
141152 /** A table with everything a player reads to take a turn. */
142153 export interface TableView extends Table {
143154 /** The public clauses being argued over, dealt from the seed when play starts. */
@@ -151,6 +162,21 @@ export interface TableView extends Table {
151162 readonly your_turn: boolean;
152163 /** The latest offer with its acceptances, or null while nothing stands. */
153164 readonly standing: Standing | null;
165 /**
166 * What this viewer may do on their own turn — offer, accept, pass, the
167 * refusal to show for each, and the aims §6 would let them swear on the
168 * standing offer, in charter order. The viewer's own seat and nobody
169 * else's: null for a viewer who holds no seat here, and null before the
170 * table is dealt, when there is no turn to take.
171 */
172 readonly may: May | null;
173 /**
174 * What the standing accord would pay this viewer's seat if it bound (§4):
175 * `score` in `src/board.ts`, over their own charge and a public accord.
176 * It is the number they are deciding on. Null with no seat of their own,
177 * no charge dealt yet, or nothing standing.
178 */
179 readonly your_payoff: number | null;
154180 /** The whole public log, newest last. */
155181 readonly acts: readonly Act[];
156182 }
@@ -531,11 +557,10 @@ function taking(db: Database, who: Who, id: unknown): Result<Taking> {
531557 * it is asked twice: once at the door, and once more inside the write.
532558 */
533559 function judged(db: Database, game: GameRow, seat: SeatRow): Result<Taking> {
534 const held = parse(seat.charge_json) as Charge | null;
535 if (held === null || !Array.isArray(held.aims))
536 return fail("conflict", "This table has not been dealt.");
537560 // The seat's own charge, under its own ordinal: §6 reads nobody else's.
538 const charge: Charge = { seat: seat.ordinal, aims: held.aims };
561 const charge = chargeOf(seat);
562 if (charge === null)
563 return fail("conflict", "This table has not been dealt.");
539564 const seats = seatRows(db, game.id);
540565 const round = roundOf(game, seats, actRows(db, game.id));
541566 return ok({
@@ -653,6 +678,45 @@ export const canJoin = (who: Who, table: Table): boolean =>
653678 export const canLeave = (who: Who, table: Table): boolean =>
654679 who.role !== "anon" && table.status === "open" && table.your_seat !== null;
655680
681 /**
682 * Whether `who` may deal this table and start its clock: its host or an
683 * admin, while it is still open, with a legal table's worth of seats taken.
684 * Exactly `startTable`'s refusals, read from a view a door already holds,
685 * so the button is there when and only when the action would agree.
686 */
687 export const canStart = (who: Who, table: Table): boolean =>
688 (who.role === "admin" || (who.role === "user" && who.id === table.host_id)) &&
689 table.status === "open" &&
690 table.seated >= SEATS.min &&
691 table.seated <= SEATS.max;
692
693 /**
694 * A seat's own charge as the rules read it (`src/board.ts`), or null when
695 * the table has not been dealt. The ordinal is the seat's own, from the
696 * row rather than from the JSON, so a charge is always its holder's.
697 */
698 function chargeOf(seat: SeatRow): Charge | null {
699 const stored = parse(seat.charge_json) as Charge | null;
700 if (stored === null || !Array.isArray(stored.aims)) return null;
701 return { seat: seat.ordinal, aims: stored.aims };
702 }
703
704 /**
705 * The viewer's own seat and the charge it was dealt, or null when they hold
706 * no seat here or the table has not been dealt. The one place a read asks
707 * "which of these is mine?", so nothing downstream can answer it wrongly.
708 */
709 function dealtIn(
710 seats: readonly SeatRow[],
711 who: Who,
712 ): { seat: SeatRow; charge: Charge } | null {
713 if (who.role === "anon") return null;
714 const seat = seats.find((one) => one.player_id === who.id);
715 if (seat === undefined) return null;
716 const charge = chargeOf(seat);
717 return charge === null ? null : { seat, charge };
718 }
719
656720 /**
657721 * Whether `who` may read what `seat` is playing for. A charge is the game:
658722 * its holder sees it from the moment it is dealt, everyone else only once
@@ -715,11 +779,22 @@ function view(db: Database, game: GameRow, who: Who): TableView {
715779 const ordinal = playing ? whoseTurn(round) : null;
716780 const turn = ordinal === null ? null : (idOf.get(ordinal) ?? null);
717781 const stands = playing ? whatStands(round) : null;
782 // §5 and §6 answered once, for the viewer's own seat: the doors draw their
783 // buttons from this rather than reading the rules a second time. A seat
784 // without a charge has not been dealt in, and `mayAct` reads nobody's
785 // charge but the one it is handed.
786 const own = dealtIn(seats, who);
718787 return {
719788 ...seen,
720789 charter: (parse(game.charter_json) as Clause[] | null) ?? null,
721790 turn,
722791 your_turn: turn !== null && turn === seen.your_seat,
792 may:
793 own === null
794 ? null
795 : { ...mayAct(round, own.charge.seat, own.charge), seat: own.seat.id },
796 your_payoff:
797 own === null || stands === null ? null : score(own.charge, stands.accord),
723798 standing:
724799 stands === null
725800 ? null
src/server.test.ts +24
@@ -351,6 +351,16 @@ test("the turn is played over the API, and the status follows the failure", asyn
351351 expect(table.charter).toHaveLength(7);
352352 expect(table.turn).toBe(table.seats[0].id);
353353 expect(table.your_turn).toBe(true);
354 // The state says what this seat may do, so a door draws three buttons
355 // without reading a rule of its own: nothing stands, so there is nothing
356 // to accept and nothing on the table to be paid.
357 expect(table.may).toMatchObject({ seat: table.seats[0].id, turn: true });
358 expect(table.may.offer).toEqual({ may: true, refusal: null });
359 expect(table.may.accept).toEqual({
360 may: false,
361 refusal: "There is nothing to accept yet.",
362 });
363 expect(table.your_payoff).toBeNull();
354364 expect(
355365 (await api(`/api/tables/${id}/start`, { method: "POST" }, alice)).status,
356366 ).toBe(409);
@@ -376,6 +386,20 @@ test("the turn is played over the API, and the status follows the failure", asyn
376386 );
377387 expect(offered.status).toBe(200);
378388 expect(((await offered.json()) as any).standing.accord).toEqual(accord);
389
390 // The offer was bent to pay one of bob's aims, so §6 has him something to
391 // swear and the state hands it to him, with the sum he is deciding on —
392 // `score`, over his own charge, and nobody else's arithmetic.
393 const held = mine.seats.find((seat: any) => seat.player_name === "bob").charge;
394 const toBob = await stateOf(id, bob);
395 expect(toBob.may.turn).toBe(true);
396 expect(toBob.may.accept).toEqual({ may: true, refusal: null });
397 expect(toBob.may.cite).toContainEqual(aim);
398 expect(toBob.your_payoff).toBe(score(held, accord));
399 // A guest is watching the same table and is told nothing of either.
400 const guest = await stateOf(id, {});
401 expect(guest.may).toBeNull();
402 expect(guest.your_payoff).toBeNull();
379403 // A second act on the same turn is a conflict, by name.
380404 const again = await api(
381405 `/api/tables/${id}/offer`,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs