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
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/74-what-you-may-do-and-what-it-would-pay-yo → main 2 commits fork
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 | ||
| 130 | 130 | clears every acceptance by existing, and there is no pointer or tally that |
| 131 | 131 | can fall out of step with the record. |
| 132 | 132 | |
| 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 | ||
| 133 | 145 | Because the turn is a count of that ledger, two doors of one seat could |
| 134 | 146 | read the same count at the same instant and both believe they hold the |
| 135 | 147 | 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 | ||
| 193 | 205 | `GET /api/tables/1` gives you. Accepting costs a truth (RULES.md §6): the |
| 194 | 206 | answer swears the first of your own aims the accord satisfies, or the one |
| 195 | 207 | 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. | |
| 197 | 210 | |
| 198 | 211 | A refusal answers with its name and the status that matches it: |
| 199 | 212 | `signin_required` 401, `forbidden` 403 (no seat of yours here), |
src/actions.test.ts +186 −1
| @@ -7,6 +7,7 @@ import { | ||
| 7 | 7 | acceptOffer, |
| 8 | 8 | canJoin, |
| 9 | 9 | canLeave, |
| 10 | canStart, | |
| 10 | 11 | joinTable, |
| 11 | 12 | leaveTable, |
| 12 | 13 | listTables, |
| @@ -19,8 +20,18 @@ import { | ||
| 19 | 20 | TITLE_MAX, |
| 20 | 21 | type TableView, |
| 21 | 22 | } from "./actions.ts"; |
| 23 | import { type Charge, score } from "./board.ts"; | |
| 22 | 24 | 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"; | |
| 24 | 35 | import { subscribe } from "./live.ts"; |
| 25 | 36 | import type { Who } from "./who.ts"; |
| 26 | 37 | |
| @@ -429,6 +440,29 @@ test("an admin may start a table that is not theirs", () => { | ||
| 429 | 440 | expect(startTable(db, root, { id: table.id }).ok).toBe(true); |
| 430 | 441 | }); |
| 431 | 442 | |
| 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 | ||
| 432 | 466 | test("the seats a table starts with are renumbered 0…n−1, gaps closed", () => { |
| 433 | 467 | // People came and went: five seats were taken, two were given up, and one |
| 434 | 468 | // 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", | ||
| 745 | 779 | }); |
| 746 | 780 | }); |
| 747 | 781 | |
| 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 | ||
| 748 | 933 | /** |
| 749 | 934 | * One door, in its own process, taking one turn per tick of a clock every |
| 750 | 935 | * other door is watching. `bun:sqlite` is synchronous, so two promises are |
src/actions.ts +79 −4
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | type Cite, |
| 25 | 25 | type Clause, |
| 26 | 26 | deal, |
| 27 | score, | |
| 27 | 28 | validAccord, |
| 28 | 29 | } from "./board.ts"; |
| 29 | 30 | import { allow } from "./http.ts"; |
| @@ -138,6 +139,16 @@ export interface Standing { | ||
| 138 | 139 | readonly acceptances: readonly number[]; |
| 139 | 140 | } |
| 140 | 141 | |
| 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 | ||
| 141 | 152 | /** A table with everything a player reads to take a turn. */ |
| 142 | 153 | export interface TableView extends Table { |
| 143 | 154 | /** The public clauses being argued over, dealt from the seed when play starts. */ |
| @@ -151,6 +162,21 @@ export interface TableView extends Table { | ||
| 151 | 162 | readonly your_turn: boolean; |
| 152 | 163 | /** The latest offer with its acceptances, or null while nothing stands. */ |
| 153 | 164 | 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; | |
| 154 | 180 | /** The whole public log, newest last. */ |
| 155 | 181 | readonly acts: readonly Act[]; |
| 156 | 182 | } |
| @@ -531,11 +557,10 @@ function taking(db: Database, who: Who, id: unknown): Result<Taking> { | ||
| 531 | 557 | * it is asked twice: once at the door, and once more inside the write. |
| 532 | 558 | */ |
| 533 | 559 | 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."); | |
| 537 | 560 | // 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."); | |
| 539 | 564 | const seats = seatRows(db, game.id); |
| 540 | 565 | const round = roundOf(game, seats, actRows(db, game.id)); |
| 541 | 566 | return ok({ |
| @@ -653,6 +678,45 @@ export const canJoin = (who: Who, table: Table): boolean => | ||
| 653 | 678 | export const canLeave = (who: Who, table: Table): boolean => |
| 654 | 679 | who.role !== "anon" && table.status === "open" && table.your_seat !== null; |
| 655 | 680 | |
| 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 | ||
| 656 | 720 | /** |
| 657 | 721 | * Whether `who` may read what `seat` is playing for. A charge is the game: |
| 658 | 722 | * 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 { | ||
| 715 | 779 | const ordinal = playing ? whoseTurn(round) : null; |
| 716 | 780 | const turn = ordinal === null ? null : (idOf.get(ordinal) ?? null); |
| 717 | 781 | 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); | |
| 718 | 787 | return { |
| 719 | 788 | ...seen, |
| 720 | 789 | charter: (parse(game.charter_json) as Clause[] | null) ?? null, |
| 721 | 790 | turn, |
| 722 | 791 | 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), | |
| 723 | 798 | standing: |
| 724 | 799 | stands === null |
| 725 | 800 | ? 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 | ||
| 351 | 351 | expect(table.charter).toHaveLength(7); |
| 352 | 352 | expect(table.turn).toBe(table.seats[0].id); |
| 353 | 353 | 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(); | |
| 354 | 364 | expect( |
| 355 | 365 | (await api(`/api/tables/${id}/start`, { method: "POST" }, alice)).status, |
| 356 | 366 | ).toBe(409); |
| @@ -376,6 +386,20 @@ test("the turn is played over the API, and the status follows the failure", asyn | ||
| 376 | 386 | ); |
| 377 | 387 | expect(offered.status).toBe(200); |
| 378 | 388 | 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(); | |
| 379 | 403 | // A second act on the same turn is a conflict, by name. |
| 380 | 404 | const again = await api( |
| 381 | 405 | `/api/tables/${id}/offer`, |
new above
offline