@leafarcade/cli 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Let your own agent play a private board-game room while you use the browser board. The CLI reads positions and submits moves; it does not call a model, collect model credentials or implement its own opponent.
4
4
 
5
- **Scope of 0.9.0:** all 53 multiplayer games in unclocked, unrated rooms that admit agents. Nine games expose complete convenience move lists and 41 more list legal wire actions in `legal_wire_actions` (marked incomplete only in free-form phases such as melds, trade offers, raise amounts, bluffs and four-card discards); Categories, Draw & Guess and Letter Tiles use their documented free-form actions. Card games add `cards_labeled`, index boards add `board_labeled` and `board_legend`, and every read adds `changed_since_last_read`. Local automatic play is available through the watcher below. OAuth login, seat-scoped pairing and resuming a stopped desktop chat are not implemented. Approval-only private tables require the host to approve a joining player before that seat is admitted. Invite-only tables admit players holding the invitation. Spectator and agent admission follow the host’s settings.
5
+ **Scope of 0.9.1:** all 53 multiplayer games in unclocked, unrated rooms that admit agents. Nine games expose complete convenience move lists and 41 more list legal wire actions in `legal_wire_actions` (marked incomplete only in free-form phases such as melds, trade offers, raise amounts, bluffs and four-card discards); Categories, Draw & Guess and Letter Tiles use their documented free-form actions. Card games add `cards_labeled`, index boards add `board_labeled` and `board_legend`, and every read adds `changed_since_last_read`. Local automatic play is available through the watcher below. OAuth login, seat-scoped pairing and resuming a stopped desktop chat are not implemented. Approval-only private tables require the host to approve a joining player before that seat is admitted. Invite-only tables admit players holding the invitation. Spectator and agent admission follow the host’s settings.
6
6
 
7
7
  ## Start a game from your agent
8
8
 
package/dist/cli.mjs CHANGED
@@ -3758,6 +3758,17 @@ function drawReason(history) {
3758
3758
  const last = history.at(-1);
3759
3759
  return history.filter((key2) => key2 === last).length >= 3 ? "Draw \xB7 the same position occurred three times." : "Draw \xB7 too many moves without progress (no capture or man move).";
3760
3760
  }
3761
+ function snapshotTurn(before, after, owner4, movers) {
3762
+ if (!before || !after || before.length !== after.length) return null;
3763
+ for (const seat of movers) {
3764
+ const to = after.findIndex((p, i) => owner4(p) === seat && owner4(before[i]) !== seat);
3765
+ if (to < 0) continue;
3766
+ const from = before.findIndex((p, i) => owner4(p) === seat && owner4(after[i]) !== seat);
3767
+ const captured = before.flatMap((p, i) => owner4(p) === 1 - seat && owner4(after[i]) !== 1 - seat ? [i] : []);
3768
+ return { by_seat: seat, ...from >= 0 ? { from } : {}, to, ...captured.length ? { captured } : {} };
3769
+ }
3770
+ return null;
3771
+ }
3761
3772
 
3762
3773
  // lib/random.ts
3763
3774
  function randomStep(seed) {
@@ -3796,6 +3807,261 @@ function best(scores2) {
3796
3807
  return winners.length === 1 ? winners[0] : null;
3797
3808
  }
3798
3809
 
3810
+ // lib/multiplayer/dice.ts
3811
+ var diceCategories = [
3812
+ "Ones",
3813
+ "Twos",
3814
+ "Threes",
3815
+ "Fours",
3816
+ "Fives",
3817
+ "Sixes",
3818
+ "Three of a kind",
3819
+ "Four of a kind",
3820
+ "Full house",
3821
+ "Small straight",
3822
+ "Large straight",
3823
+ "Five of a kind",
3824
+ "Chance"
3825
+ ];
3826
+ function createDice(count) {
3827
+ if (!index(count - 2, 3)) throw Error("Choose two to four players.");
3828
+ return {
3829
+ dice: [0, 0, 0, 0, 0],
3830
+ held: Array(5).fill(false),
3831
+ rolls: 0,
3832
+ sheets: Array.from({ length: count }, () => Array(13).fill(null)),
3833
+ bonuses: Array(count).fill(0),
3834
+ turn: 0,
3835
+ finished: false,
3836
+ winner: null
3837
+ };
3838
+ }
3839
+ function diceScore(dice, category, joker = false) {
3840
+ const counts = Array.from(
3841
+ { length: 6 },
3842
+ (_, i) => dice.filter((v) => v === i + 1).length
3843
+ ), sum = dice.reduce((a, b) => a + b, 0);
3844
+ if (category < 6) return counts[category] * (category + 1);
3845
+ switch (category) {
3846
+ case 6:
3847
+ return Math.max(...counts) >= 3 ? sum : 0;
3848
+ case 7:
3849
+ return Math.max(...counts) >= 4 ? sum : 0;
3850
+ case 8:
3851
+ return joker || counts.includes(3) && counts.includes(2) ? 25 : 0;
3852
+ case 9:
3853
+ return joker || [0, 1, 2].some((start) => counts.slice(start, start + 4).every(Boolean)) ? 30 : 0;
3854
+ case 10:
3855
+ return joker || [0, 1].some((start) => counts.slice(start, start + 5).every(Boolean)) ? 40 : 0;
3856
+ case 11:
3857
+ return counts.includes(5) ? 50 : 0;
3858
+ case 12:
3859
+ return sum;
3860
+ default:
3861
+ throw Error("Choose a score category.");
3862
+ }
3863
+ }
3864
+ function diceTotal(s, seat) {
3865
+ const sheet = s.sheets[seat], upper = sheet.slice(0, 6).reduce((sum, n) => sum + (n ?? 0), 0);
3866
+ return sheet.reduce((sum, n) => sum + (n ?? 0), 0) + (upper >= 63 ? 35 : 0) + s.bonuses[seat];
3867
+ }
3868
+ function playDice(s, type, at, entropy2) {
3869
+ if (s.finished) throw Error("This match is finished.");
3870
+ if (type === "roll") {
3871
+ if (s.rolls >= 3) throw Error("Record a score after three rolls.");
3872
+ if (s.rolls && s.held.every(Boolean)) throw Error("Release a die before rolling again, or record a score.");
3873
+ const random = draws(entropy2, 5);
3874
+ return {
3875
+ ...s,
3876
+ rolls: s.rolls + 1,
3877
+ dice: s.dice.map(
3878
+ (v, i) => s.rolls && s.held[i] ? v : 1 + Math.floor(random[i] * 6)
3879
+ )
3880
+ };
3881
+ }
3882
+ if (at === void 0 || !index(at, type === "hold" ? 5 : 13) || !s.rolls)
3883
+ throw Error("Roll first, then choose a die or score category.");
3884
+ if (type === "hold") {
3885
+ if (s.rolls >= 3) throw Error("No rolls are left: record a score.");
3886
+ return { ...s, held: s.held.map((v, i) => i === at ? !v : v) };
3887
+ }
3888
+ if (type !== "score" || s.sheets[s.turn][at] !== null)
3889
+ throw Error("Choose an unused score category.");
3890
+ const sheets = s.sheets.map((row) => [...row]), bonuses = [...s.bonuses];
3891
+ const five = s.dice.every((v) => v === s.dice[0]), upper = s.dice[0] - 1;
3892
+ const joker = five && sheets[s.turn][11] !== null && sheets[s.turn][upper] !== null;
3893
+ if (joker && sheets[s.turn].slice(6).some((n) => n === null) && at < 6)
3894
+ throw Error(
3895
+ "Use the five-of-a-kind joker in an unused lower category first."
3896
+ );
3897
+ sheets[s.turn][at] = diceScore(s.dice, at, joker);
3898
+ if (joker && sheets[s.turn][11] === 50) bonuses[s.turn] += 100;
3899
+ const next3 = {
3900
+ ...s,
3901
+ sheets,
3902
+ bonuses,
3903
+ turn: (s.turn + 1) % s.sheets.length,
3904
+ rolls: 0,
3905
+ held: Array(5).fill(false),
3906
+ dice: [0, 0, 0, 0, 0]
3907
+ };
3908
+ next3.finished = sheets.every((row) => row.every((n) => n !== null));
3909
+ if (next3.finished)
3910
+ next3.winner = best(sheets.map((_, i) => diceTotal(next3, i)));
3911
+ return next3;
3912
+ }
3913
+
3914
+ // lib/multiplayer/ludo.ts
3915
+ function createLudo(count) {
3916
+ if (!index(count - 2, 3)) throw Error("Choose two to four players.");
3917
+ return {
3918
+ pieces: Array.from({ length: count }, () => [-1, -1, -1, -1]),
3919
+ turn: 0,
3920
+ die: 0,
3921
+ streak: 0,
3922
+ attempts: 0,
3923
+ winner: null,
3924
+ last: "Roll a six to leave your yard."
3925
+ };
3926
+ }
3927
+ var ludoTrack = (seat, progress, count = 4) => (seat * (count === 2 ? 20 : 10) + progress) % 40;
3928
+ function ludoMoves(s) {
3929
+ if (!s.die || s.winner !== null) return [];
3930
+ return s.pieces[s.turn].flatMap((p, i) => {
3931
+ const to = p < 0 ? s.die === 6 ? 0 : -1 : p + s.die;
3932
+ return to >= 0 && to <= 43 && !s.pieces[s.turn].some((other, j) => j !== i && other === to) ? [i] : [];
3933
+ });
3934
+ }
3935
+ function advance(s) {
3936
+ return {
3937
+ ...s,
3938
+ turn: (s.turn + 1) % s.pieces.length,
3939
+ die: 0,
3940
+ streak: 0,
3941
+ attempts: 0
3942
+ };
3943
+ }
3944
+ function playLudo(s, type, at, entropy2) {
3945
+ if (s.winner !== null) throw Error("This match is finished.");
3946
+ if (type === "roll") {
3947
+ if (s.die) throw Error("Move a piece before rolling again.");
3948
+ const die = 1 + Math.floor(draws(entropy2, 1)[0] * 6), streak = die === 6 ? s.streak + 1 : 0;
3949
+ let next4 = {
3950
+ ...s,
3951
+ die,
3952
+ streak,
3953
+ attempts: s.attempts + 1,
3954
+ last: `Player ${s.turn + 1} rolled ${die}.`
3955
+ };
3956
+ if (streak > 3)
3957
+ return advance({
3958
+ ...next4,
3959
+ last: "Three extra moves is the limit. Turn passes."
3960
+ });
3961
+ if (!ludoMoves(next4).length) {
3962
+ const allHome = s.pieces[s.turn].every((p) => p === -1);
3963
+ next4 = allHome && next4.attempts < 3 || die === 6 ? { ...next4, die: 0, last: `Player ${s.turn + 1} rolled ${die}: no piece can move${die === 6 ? "; roll again" : allHome ? `; ${3 - next4.attempts} more tr${3 - next4.attempts === 1 ? "y" : "ies"} for a 6` : ""}.` } : advance({ ...next4, last: `Player ${s.turn + 1} rolled ${die}: no piece can move. Turn passes.` });
3964
+ }
3965
+ return next4;
3966
+ }
3967
+ if (type !== "move" || at === void 0 || !index(at, 4) || !ludoMoves(s).includes(at))
3968
+ throw Error(
3969
+ "Choose a highlighted piece. You need an exact roll to reach home."
3970
+ );
3971
+ const pieces = s.pieces.map((row) => [...row]), old = pieces[s.turn][at], to = old < 0 ? 0 : old + s.die;
3972
+ pieces[s.turn][at] = to;
3973
+ const captured = [];
3974
+ if (to < 40) {
3975
+ for (let seat = 0; seat < pieces.length; seat++)
3976
+ if (seat !== s.turn) {
3977
+ pieces[seat] = pieces[seat].map((p, piece2) => {
3978
+ const hit = p >= 0 && p < 40 && ludoTrack(seat, p, pieces.length) === ludoTrack(s.turn, to, pieces.length);
3979
+ if (hit) captured.push(`Player ${seat + 1}'s piece ${piece2 + 1}`);
3980
+ return hit ? -1 : p;
3981
+ });
3982
+ }
3983
+ }
3984
+ const won = pieces[s.turn].every((p) => p >= 40);
3985
+ const next3 = {
3986
+ ...s,
3987
+ pieces,
3988
+ die: 0,
3989
+ attempts: 0,
3990
+ winner: won ? s.turn : null,
3991
+ last: `Player ${s.turn + 1} moved piece ${at + 1} ${old < 0 ? "out of the yard to 0" : `from ${old} to ${to}`} with a ${s.die}${to >= 40 ? " (home lane)" : ""}${captured.length ? `, sending ${captured.join(" and ")} back to the yard` : ""}${!won && s.die === 6 && s.streak < 3 ? "; the 6 gives another roll" : ""}.`
3992
+ };
3993
+ return s.die === 6 && s.streak < 3 || won ? next3 : advance(next3);
3994
+ }
3995
+
3996
+ // lib/multiplayer/football.ts
3997
+ var footballPoint = (at) => [at % 9, Math.floor(at / 9)];
3998
+ var footballEdge = (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
3999
+ var valid = (at) => index(at, 117) && (Math.floor(at / 9) > 0 && Math.floor(at / 9) < 12 || at % 9 >= 3 && at % 9 <= 5);
4000
+ var boundary = (at) => {
4001
+ const [x, y] = footballPoint(at);
4002
+ return x === 0 || x === 8 || y === 0 || y === 12 || (y === 1 || y === 11) && x !== 4;
4003
+ };
4004
+ function createFootball() {
4005
+ const edges = [];
4006
+ for (let y = 1; y <= 11; y++)
4007
+ for (const x of [0, 8])
4008
+ if (y < 11) edges.push(footballEdge(y * 9 + x, (y + 1) * 9 + x));
4009
+ for (const y of [1, 11])
4010
+ for (let x = 0; x < 8; x++)
4011
+ if (x < 3 || x >= 5) edges.push(footballEdge(y * 9 + x, y * 9 + x + 1));
4012
+ for (const y of [0, 12]) {
4013
+ edges.push(
4014
+ footballEdge(y * 9 + 3, y * 9 + 4),
4015
+ footballEdge(y * 9 + 4, y * 9 + 5)
4016
+ );
4017
+ for (const x of [3, 5])
4018
+ edges.push(footballEdge(y * 9 + x, (y === 0 ? 1 : 11) * 9 + x));
4019
+ }
4020
+ return { ball: 58, edges, turn: 0, winner: null, last: null };
4021
+ }
4022
+ function footballMoves(s) {
4023
+ if (s.winner !== null) return [];
4024
+ const [x, y] = footballPoint(s.ball), moves = [];
4025
+ for (let dy = -1; dy <= 1; dy++)
4026
+ for (let dx = -1; dx <= 1; dx++) {
4027
+ const nx = x + dx, ny = y + dy, at = ny * 9 + nx;
4028
+ if ((ny === 0 || ny === 12) && (x < 3 || x > 5)) continue;
4029
+ if ((dx || dy) && nx >= 0 && nx <= 8 && ny >= 0 && ny <= 12 && valid(at) && !s.edges.includes(footballEdge(s.ball, at)))
4030
+ moves.push(at);
4031
+ }
4032
+ return moves;
4033
+ }
4034
+ function playFootball(s, at) {
4035
+ if (!footballMoves(s).includes(at))
4036
+ throw Error("Choose an adjacent point along an unused line.");
4037
+ const bounced = boundary(at) || s.edges.some((edge) => edge.split(":").map(Number).includes(at));
4038
+ const y = Math.floor(at / 9);
4039
+ const next3 = {
4040
+ ...s,
4041
+ ball: at,
4042
+ edges: [...s.edges, footballEdge(s.ball, at)],
4043
+ turn: bounced ? s.turn : s.turn === 0 ? 1 : 0,
4044
+ winner: y === 0 ? 0 : y === 12 ? 1 : null,
4045
+ last: [s.ball, at]
4046
+ };
4047
+ if (next3.winner === null && !footballMoves(next3).length)
4048
+ next3.winner = 1 - next3.turn;
4049
+ return next3;
4050
+ }
4051
+ var compass = { "0,-1": "up", "1,-1": "up-right", "1,0": "right", "1,1": "down-right", "0,1": "down", "-1,1": "down-left", "-1,0": "left", "-1,-1": "up-left" };
4052
+ function footballLabeled(s) {
4053
+ const [x, y] = footballPoint(s.ball);
4054
+ return [
4055
+ `ball: point ${s.ball} (row ${y}, column ${x}); points are row*9+column on a 9-column, 13-row grid, rows 0 and 12 are the goals`,
4056
+ "seat 0 scores in the top goal (row 0: points 3, 4, 5); seat 1 in the bottom goal (row 12: points 111, 112, 113)",
4057
+ ...footballMoves(s).map((at) => {
4058
+ const [nx, ny] = footballPoint(at);
4059
+ const bounce = boundary(at) || s.edges.some((edge) => edge.split(":").map(Number).includes(at));
4060
+ return `move to ${at}: ${compass[`${nx - x},${ny - y}`]}${ny === 0 || ny === 12 ? ", goal" : bounce ? ", bounce: you move again" : ""}`;
4061
+ })
4062
+ ];
4063
+ }
4064
+
3799
4065
  // lib/multiplayer/mills.ts
3800
4066
  var millLines = [
3801
4067
  [0, 1, 2],
@@ -4409,7 +4675,7 @@ var multiplayerGames = [
4409
4675
  symbol: "1000",
4410
4676
  players: "2\u20134 players",
4411
4677
  description: "Bid your hand. Marry a new trump.",
4412
- rules: "A 24-card deck, nine through ace; strength is nine, jack, queen, king, ten, ace. Three active players receive seven cards and a three-card kitty. With four seats the dealer sits out and scores revealed kitty aces (50 each) and marriages. Two players receive ten cards each and choose one of two two-card kitties. The first active player opens at 100; bid in tens, no higher than 120 plus held marriages. Winner takes a kitty, gives each other active player a card (or discards two with two seats), then declares at least the auction bid. Follow suit and beat the winner when possible; if void, trump when held. Leading a held queen-king pair with the marriage declaration earns clubs 60, diamonds 80, hearts 100 or spades 40 and changes trump. Card points: ace 11, ten 10, king 4, queen 3, jack 2. Cards left from the kitties, including the declarer\u2019s two discards in a two-player game, score for the last trick winner. Declarer makes or loses their contract; others round their points to tens. At 800, only declarer earns further points. First to 1000 wins, with declarer winning a simultaneous finish. The first bomb cancels freely; subsequent bombs award opponents 60 (40 with four seats). Four nines can request a redeal."
4678
+ rules: "A 24-card deck, nine through ace; strength from lowest to highest is nine, jack, queen, king, ten, ace. Three active players receive seven cards and a three-card kitty. With four seats the dealer sits out and scores revealed kitty aces (50 each) and marriages. Two players receive ten cards each and choose one of two two-card kitties. The first active player opens at 100; bid in tens, no higher than 120 plus held marriages. Winner takes a kitty, gives each other active player a card (or discards two with two seats), then declares at least the auction bid. Follow suit and beat the winner when possible; if void, trump when held. Leading a held queen-king pair with the marriage declaration earns clubs 60, diamonds 80, hearts 100 or spades 40 and changes trump. Card points: ace 11, ten 10, king 4, queen 3, jack 2. Cards left from the kitties, including the declarer\u2019s two discards in a two-player game, score for the last trick winner. Declarer makes or loses their contract; others round their points to tens. At 800, only declarer earns further points. First to 1000 wins, with declarer winning a simultaneous finish. The first bomb cancels freely; subsequent bombs award opponents 60 (40 with four seats). Four nines can request a redeal."
4413
4679
  },
4414
4680
  {
4415
4681
  id: "binokel",
@@ -4529,7 +4795,7 @@ var multiplayerGames = [
4529
4795
  symbol: "\u2654",
4530
4796
  players: "2 players",
4531
4797
  description: "Claim sixty-six. Close the stock with care.",
4532
- rules: "Six cards each from a 24-card deck. Rank order: nine, jack, queen, king, ten, ace; values 0, 2, 3, 4, 10, 11. While stock is open, play freely; trick winner draws first, and the trump upcard is the last card drawn. Exchange trump nine after winning a trick. Before leading, you may close the stock; then follow suit, overtake if possible, otherwise trump. A queen\u2013king marriage declared while leading with open stock earns 20, or 40 in trump, once you have won a trick (including the one it leads). Claim 66 before leading, or immediately with a marriage. False claims or failed closure award the opponent 2/3 match points. Otherwise round wins earn 1\u20133 based on opponent points. An unclosed last trick adds ten. First to seven match points wins."
4798
+ rules: "Six cards each from a 24-card deck. Rank order from lowest to highest: nine, jack, queen, king, ten, ace; values 0, 2, 3, 4, 10, 11. While stock is open, play freely; trick winner draws first, and the trump upcard is the last card drawn. Exchange trump nine after winning a trick. Before leading, you may close the stock; then follow suit, overtake if possible, otherwise trump. A queen\u2013king marriage declared while leading with open stock earns 20, or 40 in trump, once you have won a trick (including the one it leads). Claim 66 before leading, or immediately with a marriage. False claims or failed closure award the opponent 2/3 match points. Otherwise round wins earn 1\u20133 based on opponent points. An unclosed last trick adds ten. First to seven match points wins."
4533
4799
  },
4534
4800
  {
4535
4801
  id: "spades",
@@ -5004,107 +5270,6 @@ var action_catalog_default = {
5004
5270
  ]
5005
5271
  };
5006
5272
 
5007
- // lib/multiplayer/dice.ts
5008
- var diceCategories = [
5009
- "Ones",
5010
- "Twos",
5011
- "Threes",
5012
- "Fours",
5013
- "Fives",
5014
- "Sixes",
5015
- "Three of a kind",
5016
- "Four of a kind",
5017
- "Full house",
5018
- "Small straight",
5019
- "Large straight",
5020
- "Five of a kind",
5021
- "Chance"
5022
- ];
5023
- function createDice(count) {
5024
- if (!index(count - 2, 3)) throw Error("Choose two to four players.");
5025
- return {
5026
- dice: [0, 0, 0, 0, 0],
5027
- held: Array(5).fill(false),
5028
- rolls: 0,
5029
- sheets: Array.from({ length: count }, () => Array(13).fill(null)),
5030
- bonuses: Array(count).fill(0),
5031
- turn: 0,
5032
- finished: false,
5033
- winner: null
5034
- };
5035
- }
5036
- function diceScore(dice, category, joker = false) {
5037
- const counts = Array.from(
5038
- { length: 6 },
5039
- (_, i) => dice.filter((v) => v === i + 1).length
5040
- ), sum = dice.reduce((a, b) => a + b, 0);
5041
- if (category < 6) return counts[category] * (category + 1);
5042
- switch (category) {
5043
- case 6:
5044
- return Math.max(...counts) >= 3 ? sum : 0;
5045
- case 7:
5046
- return Math.max(...counts) >= 4 ? sum : 0;
5047
- case 8:
5048
- return joker || counts.includes(3) && counts.includes(2) ? 25 : 0;
5049
- case 9:
5050
- return joker || [0, 1, 2].some((start) => counts.slice(start, start + 4).every(Boolean)) ? 30 : 0;
5051
- case 10:
5052
- return joker || [0, 1].some((start) => counts.slice(start, start + 5).every(Boolean)) ? 40 : 0;
5053
- case 11:
5054
- return counts.includes(5) ? 50 : 0;
5055
- case 12:
5056
- return sum;
5057
- default:
5058
- throw Error("Choose a score category.");
5059
- }
5060
- }
5061
- function diceTotal(s, seat) {
5062
- const sheet = s.sheets[seat], upper = sheet.slice(0, 6).reduce((sum, n) => sum + (n ?? 0), 0);
5063
- return sheet.reduce((sum, n) => sum + (n ?? 0), 0) + (upper >= 63 ? 35 : 0) + s.bonuses[seat];
5064
- }
5065
- function playDice(s, type, at, entropy2) {
5066
- if (s.finished) throw Error("This match is finished.");
5067
- if (type === "roll") {
5068
- if (s.rolls >= 3) throw Error("Record a score after three rolls.");
5069
- const random = draws(entropy2, 5);
5070
- return {
5071
- ...s,
5072
- rolls: s.rolls + 1,
5073
- dice: s.dice.map(
5074
- (v, i) => s.rolls && s.held[i] ? v : 1 + Math.floor(random[i] * 6)
5075
- )
5076
- };
5077
- }
5078
- if (at === void 0 || !index(at, type === "hold" ? 5 : 13) || !s.rolls)
5079
- throw Error("Roll first, then choose a die or score category.");
5080
- if (type === "hold")
5081
- return { ...s, held: s.held.map((v, i) => i === at ? !v : v) };
5082
- if (type !== "score" || s.sheets[s.turn][at] !== null)
5083
- throw Error("Choose an unused score category.");
5084
- const sheets = s.sheets.map((row) => [...row]), bonuses = [...s.bonuses];
5085
- const five = s.dice.every((v) => v === s.dice[0]), upper = s.dice[0] - 1;
5086
- const joker = five && sheets[s.turn][11] !== null && sheets[s.turn][upper] !== null;
5087
- if (joker && sheets[s.turn].slice(6).some((n) => n === null) && at < 6)
5088
- throw Error(
5089
- "Use the five-of-a-kind joker in an unused lower category first."
5090
- );
5091
- sheets[s.turn][at] = diceScore(s.dice, at, joker);
5092
- if (joker && sheets[s.turn][11] === 50) bonuses[s.turn] += 100;
5093
- const next3 = {
5094
- ...s,
5095
- sheets,
5096
- bonuses,
5097
- turn: (s.turn + 1) % s.sheets.length,
5098
- rolls: 0,
5099
- held: Array(5).fill(false),
5100
- dice: [0, 0, 0, 0, 0]
5101
- };
5102
- next3.finished = sheets.every((row) => row.every((n) => n !== null));
5103
- if (next3.finished)
5104
- next3.winner = best(sheets.map((_, i) => diceTotal(next3, i)));
5105
- return next3;
5106
- }
5107
-
5108
5273
  // lib/agents/action-help.ts
5109
5274
  var help = {
5110
5275
  amazons: "move: numeric from/to 0\u201399 and amount for the arrow square. Select an exact legal_wire_actions entry.",
@@ -5113,49 +5278,49 @@ var help = {
5113
5278
  euchre: "Use visible_state.legalActions. trump: amount 0 clubs, 1 diamonds, 2 hearts, 3 spades; promotion alone optionally sits your partner out. pass declines trump. discard/play: at is a held card ID, not a hand index. IDs are suit*6+rank, ranks 0\u20135 mean 9,10,J,Q,K,A. The left bower belongs to trump for following suit. next deals after a hand. Partners are opposite seats; first team to 10 wins.",
5114
5279
  cribbage: "Use visible_state.legalActions: discard has exactly two distinct card IDs from your own hand; play has at: card ID; go is legal only when no held card fits under 31; next starts the following deal after counting. Card IDs are suit*13+rank-1, A=1 through K=13; suit order clubs, diamonds, hearts, spades. Crib and opponents' unplayed cards remain hidden.",
5115
5280
  fanorona: "Use visible_state.legalActions: move has numeric from/to intersection indices 0\u201344 (row*9+column), amount 0 for approach/quiet or 1 for withdrawal. stop ends an optional capture continuation. Complete legal wire actions are enumerated; a chain must use the same stone and obey direction/visited constraints.",
5116
- categories: `begin: host starts a timed round; promotion:'untimed' disables the timer. submit/stop: text is a string holding a JSON array of six English answers in category order, for example text set to the string '["Apple","Ant",...]' (not a raw array); stop needs a complete sheet. expire reveals a timed-out round. vote: cards is rejected cell IDs (owner seat * 6 + category index), excluding your own row; an empty array accepts all other answers. next: host advances. Answers and ballots lock once submitted. Include at: the visible zero-based round and scoreKey: the visible roundKey on submit/stop/vote/expire to allow independent same-round actions arriving on the same revision.`,
5281
+ categories: `begin: host starts a timed round; promotion:'untimed' disables the timer. submit/stop: text is a string holding a JSON array of six English answers in category order, for example text set to the string '["Apple","Ant",...]' (not a raw array); stop needs a complete sheet. expire reveals a timed-out round. vote: cards is rejected cell IDs (owner seat * 6 + category index), excluding your own row; an empty array accepts all other answers. next: host advances. Answers and ballots lock once submitted. The CLI fills at (the visible zero-based round) and scoreKey (the visible roundKey) on submit/stop/vote/expire when you leave them out, and accepts text as a plain array: {"type":"submit","text":["Spain","Seville","Seal","Soup","Singer","Spoon"]}.`,
5117
5282
  cheat: "play: cards is one to four card IDs from your hand, claiming the displayed rank. During challenge phase any other seat may challenge; only the next player may accept. Final-card claims must resolve before a win.",
5118
5283
  president: "play: cards is one to four equal-rank card IDs. Match pile size and beat its rank; 2 is high. Opening group includes 3 of clubs. pass sits out until the pile clears. Finished seats leave play; order gives final places.",
5119
5284
  rummy: "stock/upcard: draw first. meld: cards is a set or run of held card IDs. layoff: at is a public meld index, cards is held card IDs to add. discard: at is a held card ID. Aces low; keep one final discard; do not immediately discard the upcard you took.",
5120
- "connect-four": "place: at is a zero-based column (0\u20136). Convenience: --column 1\u20137.",
5285
+ "connect-four": 'legal_actions list {"type":"drop","column":1\u20137}, one-based; pass one unchanged or use --column. The CLI converts it to the wire action place with zero-based at = column-1.',
5121
5286
  "tic-tac-toe": "place: at is a zero-based row-major square (0\u20138). legal_actions use one-based row/column instead; pass either form.",
5122
5287
  chess: "move: from/to are algebraic squares; promotion is q/r/b/n.",
5123
5288
  "atomic-chess": "move: from/to are algebraic squares; promotion is q/r/b/n. Explosions follow the game rules.",
5124
5289
  "three-check": "move: from/to are algebraic squares; promotion is q/r/b/n. Check counts are in visible_state.",
5125
- checkers: "move: from/to are zero-based row-major squares (0\u201363); continue a mandatory capture chain. Men move and capture forward only; kings move both ways. Convenience flags use algebraic squares.",
5290
+ checkers: 'legal_actions use algebraic squares (from "c3" to "d4"); pass one unchanged or use --from/--to. The wire move uses zero-based row-major squares (0\u201363). Continue a mandatory capture chain. Men move and capture forward only; kings move both ways. Convenience flags use algebraic squares.',
5126
5291
  "brazilian-checkers": "move: from/to are zero-based squares (0\u201363); maximum captures apply.",
5127
5292
  "international-draughts": "move: from/to are zero-based squares (0\u201399). sequence: cards is a complete legal path of square indexes, including the origin.",
5128
- reversi: "place: at is a zero-based row-major square (0\u201363). Unavailable turns pass automatically.",
5129
- gomoku: "place: at is a zero-based row-major intersection (0\u2013224). Exactly five wins.",
5130
- hex: "place: at is a zero-based row-major hexagon. swap: no payload, only the second seat's first turn. Convenience: --row/--column or --swap.",
5293
+ reversi: "legal_actions use one-based row/column (1\u20138); pass one unchanged or use --row/--column. The wire place uses zero-based row-major at (0\u201363). Unavailable turns pass automatically.",
5294
+ gomoku: "legal_actions use one-based row/column (1\u201315); pass one unchanged or use --row/--column. The wire place uses zero-based row-major at (0\u2013224). Exactly five wins.",
5295
+ hex: "legal_actions use one-based row/column; pass one unchanged or use --row/--column. The wire place uses a zero-based row-major hexagon. swap: no payload, only the second seat's first turn. Convenience: --row/--column or --swap.",
5131
5296
  "dots-boxes": 'Send a legal_actions entry ({"type":"edge","edge":N}, one-based) unchanged or use --edge N; the CLI converts it to the wire action place with zero-based at = N-1 (visible_state.edges[N-1]). board_labeled lists each box with its edge numbers. Horizontal edges precede vertical edges in row-major order. Edges and box owners are in visible_state. Convenience: --edge is one-based.',
5132
- oware: "sow: at is a zero-based pit index, 0\u20135 for seat 0 and 6\u201311 for seat 1. Convenience: --pit is one-based.",
5297
+ oware: 'legal_actions list {"type":"sow","pit":N}, one-based: seat 0 owns pits 1\u20136, seat 1 pits 7\u201312; pass one unchanged or use --pit. The wire sow uses zero-based at = pit-1 (0\u20135 for seat 0, 6\u201311 for seat 1).',
5133
5298
  go: "place/dead: at is a zero-based row-major intersection (board_labeled shows row start indexes). pass/resume: no payload. accept: scoreKey names the scoring proposal you agree to; the CLI fills it from the position you read. Two passes start scoring: either seat marks dead groups, then both accept.",
5134
5299
  battleships: "auto/clear/ready: no payload. place: at is a zero-based starting square and amount selects orientation (0 horizontal, 1 vertical). fire: at is a zero-based target square. Inspect your fleet/setup phase; enemy ships remain hidden.",
5135
5300
  mills: "place/capture: at is a zero-based point index. move: from is the origin and at the destination. Completing a mill requires a subsequent capture action.",
5136
5301
  dice: `roll: no payload. hold: at is a die index (0\u20134) to toggle its hold after rolling. score: at is a category index: ${diceCategories.map((name, i) => `${i} ${name}`).join(", ")}. Inspect the roll phase and score sheet.`,
5137
5302
  ludo: "roll: no payload. move: at is the index (0\u20133) of your token. Token progress is relative to your own start: -1 yard, 0\u201339 track, 40\u201343 your home lane. Opponents' progress uses their own start, offset by 20 (two players) or 10 per seat (four) on the shared 40-square track. A 6 is needed to leave the yard; any 6 grants another roll, up to three in a row.",
5138
5303
  domino: "tile: at is your zero-based hand position (not a pip value) and amount selects the chain end: 0 left, 1 right. draw: draw until you can play, or pass when the stock is empty. next: start the following round. Scoring: open ends summing to a multiple of five score that sum; the player who empties their hand scores the opponent's pips rounded to the nearest five.",
5139
- football: "place: at is a zero-based destination point. The state records ball, used edges and continuing rebounds.",
5304
+ football: "place: at is the destination point, row*9+column on a 9-column, 13-row grid (0\u2013116; rows 0 and 12 hold only the goal points 3\u20135 and 111\u2013113). board_labeled names each legal step with its direction and whether it bounces (another move). Seat 0 attacks the top goal, seat 1 the bottom.",
5140
5305
  backgammon: "roll: no payload. move: numeric from/to and amount (die value), one die per action. Points are 0\u201323. Positive point counts are seat 0, negative seat 1. Seat 0 moves up (enters from the bar to die-1, home 18\u201323); seat 1 moves down (enters to 24-die, home 0\u20135). from -1 means the bar; to 24 bears off for both seats. Compulsory dice usage is enforced.",
5141
5306
  shogi: "move: from is a zero-based square or a hand-piece type for a drop; to is a zero-based square; promotion:'yes' promotes. Inspect your hands and piece owners.",
5142
5307
  xiangqi: "move: numeric zero-based row-major from/to on the 9\xD710 board. promotion is unused.",
5143
5308
  poker: "fold/check/call/next: no payload. raise: amount is the desired bet total. Follow phase, pot, current bet, your cards and stack. next is a between-hand host action.",
5144
- durak: "play: at is your card ID from the visible hand; take/pass: no payload. Follow attack/defence roles and visible trump/table.",
5309
+ durak: "play: at is your card ID from the visible hand (attack or add a card of a rank already on the table; defend by beating the open attack card). take: the defender picks up the table after the attackers finish adding. pass: an attacker stops adding cards; when every attacker has passed, beaten cards are discarded (or the defender takes them after take). Follow attack/defence roles and visible trump/table.",
5145
5310
  makao: "play: cards is 1\u20134 card IDs from your visible hand, all of the same rank. amount chooses an ace suit (0 clubs, 1 diamonds, 2 hearts, 3 spades) or a jack demand (rank 5\u201310 or 13 for king; 0 for no demand); visible_state.suit and demand use the same numbers; promotion:'makao' announces the last card. draw/pass/catch act according to phase. Inspect drawn-card and demand state.",
5146
5311
  pan: "play: cards is an array of card IDs from the visible hand; take/next: no payload. Follow the table rank and legal group rules.",
5147
5312
  "gin-rummy": "stock/upcard/pass/next: no payload. discard/knock: at is a card ID from the visible hand. Inspect draw/discard phase and deadwood rules.",
5148
5313
  "sixty-six": "play: at is a card ID from the visible hand; promotion:'marriage' or 'marriage-claim' declares. exchange/close/claim/next are phase-specific commands.",
5149
5314
  hearts: "select: at is a card ID from the visible hand for passing (selecting a chosen card again unselects it; visible_state.selected shows your choice), then pass. play: at is a card ID from the visible hand. moon: amount selects a scoring choice. Follow phase, lead suit and legal cards.",
5150
- spades: "bid: amount is the bid; blind/look for blind nil where offered. play: at is a card ID from the visible hand. Follow partner, phase and lead suit.",
5315
+ spades: "bid: amount is the bid; blind/look for blind nil where offered. play: at is a card ID from the visible hand. Partners are opposite: seats 0 and 2 against seats 1 and 3 (the rules text calls them Players 1 & 3 and 2 & 4). Follow partner, phase and lead suit.",
5151
5316
  "oh-hell": "bid: amount is the bid. play: at is a card ID from the visible hand. next advances a completed hand; follow dealer restrictions and lead suit.",
5152
- "three-five-eight": "exchange: at is one card ID from your visible hand. discard: cards is exactly four distinct card IDs from your visible hand. play: at is one card ID from your visible hand. contract: amount 0\u20133 chooses a suit, 4 no trumps, 5 misere. next advances the hand.",
5317
+ "three-five-eight": "exchange: at is one card ID from your visible hand. discard: cards is exactly four distinct card IDs from your visible hand; the listed sets are samples, any four of your cards are legal. play: at is one card ID from your visible hand. contract: amount 0 clubs, 1 diamonds, 2 hearts, 3 spades as trump, 4 no trumps, 5 no tricks (misere); each once per player. Cards rank 2 (low) to ace (high). next advances the hand.",
5153
5318
  bridge: "bid: amount 1\u201335 encodes level floor((amount-1)/5)+1 and strain (amount-1)%5; pass/double/redouble have no payload. play: at is the active card ID from the visible hand, including exposed dummy when you control it. Follow auction, strain and lead-suit rules.",
5154
5319
  "polish-hearts": "trump: amount is a suit index; pass: no payload, only when no layout card fits; play: at is a card ID from the visible hand. Follow the current penalty/trump/layout phase.",
5155
5320
  thousand: "bid/declare: amount is points. kitty: at selects a kitty. exchange: cards is card IDs from the visible hand. play: at is a card ID from the visible hand; to declare a marriage, lead its queen or king with promotion:'marriage'. pass/bomb/redeal/next depend on phase. Card IDs are suit*13+rank-2 (suits clubs, diamonds, hearts, spades; ranks 9 through ace). next: the dealer of the finished hand starts the next one (the deal moves on).",
5156
- binokel: "bid: amount is points; trump: amount is a suit; play: at is a card ID from the visible hand. pass/next have no payload. Meld values: aces around 10 (both sets 100), kings around 8 (80), queens around 6 (60), jacks around 4 (40), trump run 15 (150), royal marriage 4, ordinary marriage 2 per pair. A bid is the team's trick points plus melds; tricks hold 50 points per hand (scoring cards plus 2 for the last trick), so a contract rests mostly on melds. The declaring team needs 20 meld points.",
5321
+ binokel: "bid: amount is points; trump: amount is a suit; play: at is a card ID from the visible hand. pass/next have no payload. Meld values (one set, two sets): aces around 10, 100; kings around 8, 80; queens around 6, 60; jacks around 4, 40; trump run 15, 150; royal marriage 4, ordinary marriage 2 per pair. A bid is the team's trick points plus melds; tricks hold 50 points per hand (scoring cards plus 2 for the last trick), so a contract rests mostly on melds. The declaring team needs 20 meld points.",
5157
5322
  skat: "bid: amount is an auction value; hold/pass/hand/pickup act in auction/pickup phases (after a hold the bidder must bid higher or pass). discard: cards is exactly two card IDs from your visible hand. contract: amount 0 clubs, 1 diamonds, 2 hearts, 3 spades as trump, 4 grand (jacks only), 5 null, with optional promotion normal/schneider/schwarz/ouvert. play: at is a card ID from the visible hand. Jacks rank clubs, spades, hearts, diamonds. Card IDs are suit*13+rank-2 (suits clubs, diamonds, hearts, spades). next: the dealer of the finished hand starts the next one (the deal moves on).",
5158
- baska: "announce: no payload in the honor phase. contract: amount is one of 1,3,4,5,6 and must exceed the current contract. double/pass act in auction; play: at is a card ID from the visible hand; next advances the hand.",
5323
+ baska: "announce: no payload in the honor phase. contract: amount is one of 1 Wedding, 3 Grand, 4 Zolo, 5 Grand Du, 6 Zolo Du and must exceed the current contract (the other numbers name contracts not bid this way: 0 Ordinary, 2 Zolo Bieda, 7 Master, 8 Baska). double/pass act in auction; play: at is a card ID from the visible hand; next advances the hand.",
5159
5324
  canasta: "stock/next: no payload. pickup may require melds containing the top discard plus cards from your hand. meld: melds is an array of {rank,cards} groups of card IDs from the visible hand; rank is the natural rank 3\u201314 (J 11, Q 12, K 13, A 14), with twos and jokers as wild cards. cards_labeled names each card. discard: at is a card ID from the visible hand. Follow opening meld and canasta requirements. Warning: going out (melding or discarding your last card) before your side has the required canastas (two with 2 players, one each with 4) is accepted but loses the match.",
5160
5325
  "letter-tiles": "place: placements is an array of {at,rack,letter?}; at is a board index and rack a rack index, letter assigns a blank. exchange: cards is rack indexes. accept/challenge/pass/next depend on phase.",
5161
5326
  "draw-guess": "choose: at selects one of your offered prompts. draw: stroke is an array of 2\u2013256 {x,y} integer points, each coordinate 0\u20131000; optional amount is pen width 2/4/8/16 and promotion is a supported pen color. guess: text is your guess; guesses are permitted out of turn during drawing. clear/expire/next depend on phase. Hidden prompts stay hidden from guessers.",
@@ -5239,20 +5404,23 @@ function canastaCount(melds) {
5239
5404
  return melds.filter((m) => m.cards.length >= 7).length;
5240
5405
  }
5241
5406
  function finish2(t, out2, concealed = false) {
5407
+ const parts = [];
5242
5408
  for (let i = 0; i < 2; i++) {
5243
- const meld2 = t.melds[i], red = t.red[i].length, bonus = meld2.reduce(
5409
+ const meld2 = t.melds[i], red = t.red[i].length, canastas = meld2.reduce(
5244
5410
  (n, m) => n + (m.cards.length >= 7 ? m.cards.some(canastaWild) ? 300 : 500 : 0),
5245
5411
  0
5246
- ) + (red * 100 + (red === 4 ? 400 : 0)) * (meld2.length ? 1 : -1), cards = meld2.flatMap((m) => m.cards).reduce((n, c) => n + canastaValue(c), 0), held = t.hands.reduce(
5412
+ ), threes = (red * 100 + (red === 4 ? 400 : 0)) * (meld2.length ? 1 : -1), bonus = canastas + threes, cards = meld2.flatMap((m) => m.cards).reduce((n, c) => n + canastaValue(c), 0), held = t.hands.reduce(
5247
5413
  (n, h, seat) => n + (team(t, seat) === i ? h.reduce((n2, c) => n2 + canastaValue(c), 0) : 0),
5248
5414
  0
5249
5415
  );
5250
- t.scores[i] += bonus + cards - held + (out2 !== null && team(t, out2) === i ? concealed ? 200 : 100 : 0);
5416
+ const going = out2 !== null && team(t, out2) === i ? concealed ? 200 : 100 : 0;
5417
+ t.scores[i] += bonus + cards - held + going;
5418
+ parts.push(`Team ${i + 1}: melded cards ${cards}, canastas ${canastas}, red threes ${threes}${red && !meld2.length ? " (no meld, so negative)" : ""}, going out ${going}, cards left in hand -${held}`);
5251
5419
  }
5252
5420
  t.phase = t.scores.some((n) => n >= 5e3) ? "finished" : "round";
5253
5421
  t.winner = t.phase === "finished" ? best(t.scores) : null;
5254
5422
  t.turn = t.dealer;
5255
- t.result = `Hand complete. Scores ${t.scores.join(" / ")}.`;
5423
+ t.result = `Hand complete${out2 === null ? " (stock exhausted)" : ""}. ${parts.join("; ")}. Scores ${t.scores.join(" / ")}.`;
5256
5424
  }
5257
5425
  function out(t, seat) {
5258
5426
  if (t.hands[seat].length) return;
@@ -5739,7 +5907,7 @@ function award(s) {
5739
5907
  }
5740
5908
  return s;
5741
5909
  }
5742
- function advance(s) {
5910
+ function advance2(s) {
5743
5911
  const live = s.players.filter((p) => !p.folded && !p.out);
5744
5912
  if (live.length <= 1) return award(s);
5745
5913
  if (s.pending.length) {
@@ -5765,7 +5933,7 @@ function advance(s) {
5765
5933
  s.pending = s.players.flatMap((p, i) => canAct(p) ? [i] : []);
5766
5934
  if (s.pending.length <= 1) {
5767
5935
  s.pending = [];
5768
- return advance(s);
5936
+ return advance2(s);
5769
5937
  }
5770
5938
  s.turn = nextSeat(s, s.button, canAct);
5771
5939
  return s;
@@ -5814,7 +5982,7 @@ function begin(s, seed) {
5814
5982
  s.turn = nextSeat(s, big, canAct);
5815
5983
  if (!s.pending.length || s.pending.length === 1 && s.players[s.pending[0]].street >= Math.max(...s.players.map((p) => p.street))) {
5816
5984
  s.pending = [];
5817
- return advance(s);
5985
+ return advance2(s);
5818
5986
  }
5819
5987
  return s;
5820
5988
  }
@@ -5909,7 +6077,7 @@ function pokerAction(previous, seat, action, seed) {
5909
6077
  s.actedAt ??= s.players.map((player) => player.street);
5910
6078
  s.actedAt[seat] = s.bet;
5911
6079
  s.log = [...s.log, `Player ${seat + 1} ${label}`].slice(-80);
5912
- return advance(s);
6080
+ return advance2(s);
5913
6081
  }
5914
6082
  function leavePoker(previous, seat) {
5915
6083
  const s = structuredClone(previous);
@@ -5927,7 +6095,7 @@ function leavePoker(previous, seat) {
5927
6095
  return s;
5928
6096
  }
5929
6097
  if (s.turn === seat || s.players.filter((p) => !p.folded && !p.out).length <= 1)
5930
- return advance(s);
6098
+ return advance2(s);
5931
6099
  return s;
5932
6100
  }
5933
6101
  function pokerView(s, seat) {
@@ -7716,7 +7884,8 @@ function moveVariant(state, from, to, promotion = "q") {
7716
7884
  if (!parseUci(from + to)) throw Error("Choose a legal move.");
7717
7885
  const move2 = variantMoves(state).find((m) => m.from === from && m.to === to && (!m.promotion || m.promotion === promotion));
7718
7886
  if (!move2) throw Error("Choose a legal move.");
7719
- const pos = variantPosition(state), last = makeSanAndPlay(pos, move2.internal), setup = pos.toSetup();
7887
+ const pos = variantPosition(state), san = makeSanAndPlay(pos, move2.internal), setup = pos.toSetup();
7888
+ const last = san.endsWith("#") && !pos.isCheckmate() ? san.slice(0, -1) + (pos.isCheck() ? "+" : "") : san;
7720
7889
  const fen = makeFen(setup), history = [...state.history, repetitionKey(fen)];
7721
7890
  const outcome = pos.outcome();
7722
7891
  const repeated = history.filter((key2) => key2 === repetitionKey(fen)).length >= 3;
@@ -8312,13 +8481,14 @@ function playBackgammon(s, type, from, to, die, entropy2) {
8312
8481
  opening: false,
8313
8482
  rolled,
8314
8483
  dice: rolled[0] === rolled[1] ? Array(4).fill(rolled[0]) : rolled,
8315
- result: `Rolled ${rolled.join(" and ")}.`
8484
+ result: `Player ${turn + 1} rolled ${rolled.join(" and ")}.`,
8485
+ last: { by_seat: turn, rolled, moves: [] }
8316
8486
  };
8317
8487
  return backgammonMoves(next4).length ? next4 : {
8318
8488
  ...next4,
8319
8489
  dice: [],
8320
8490
  turn: turn === 0 ? 1 : 0,
8321
- result: "All moves are blocked. Turn passes."
8491
+ result: `Player ${turn + 1} rolled ${rolled.join(" and ")}: all moves are blocked. Turn passes.`
8322
8492
  };
8323
8493
  }
8324
8494
  const move2 = backgammonMoves(s).find(
@@ -8329,6 +8499,7 @@ function playBackgammon(s, type, from, to, die, entropy2) {
8329
8499
  "Use the maximum number of dice; if only one can be used, use the higher die."
8330
8500
  );
8331
8501
  const next3 = step(s, move2);
8502
+ next3.last = { by_seat: s.turn, rolled: s.last?.rolled ?? s.rolled, moves: [...s.last?.by_seat === s.turn ? s.last.moves : [], { ...move2, ...move2.to !== 24 && s.points[move2.to] === (s.turn === 0 ? -1 : 1) ? { hit: true } : {} }] };
8332
8503
  if (next3.off[s.turn] === 15) {
8333
8504
  const opponent = 1 - s.turn, backgammon = next3.bar[opponent] > 0 || next3.points.some(
8334
8505
  (n, i) => (s.turn === 0 ? i >= 18 : i < 6) && n * (s.turn === 0 ? -1 : 1) > 0
@@ -8336,147 +8507,13 @@ function playBackgammon(s, type, from, to, die, entropy2) {
8336
8507
  next3.winner = s.turn;
8337
8508
  next3.result = next3.off[opponent] ? "Single game." : backgammon ? "Backgammon \xB7 3 points." : "Gammon \xB7 2 points.";
8338
8509
  } else if (!backgammonMoves(next3).length) {
8510
+ if (next3.dice.length) next3.result = `Player ${s.turn + 1} cannot use the remaining ${next3.dice.join(" and ")}. Turn passes.`;
8339
8511
  next3.dice = [];
8340
8512
  next3.turn = s.turn === 0 ? 1 : 0;
8341
8513
  }
8342
8514
  return next3;
8343
8515
  }
8344
8516
 
8345
- // lib/multiplayer/football.ts
8346
- var footballPoint = (at) => [at % 9, Math.floor(at / 9)];
8347
- var footballEdge = (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
8348
- var valid = (at) => index(at, 117) && (Math.floor(at / 9) > 0 && Math.floor(at / 9) < 12 || at % 9 >= 3 && at % 9 <= 5);
8349
- var boundary = (at) => {
8350
- const [x, y] = footballPoint(at);
8351
- return x === 0 || x === 8 || y === 0 || y === 12 || (y === 1 || y === 11) && x !== 4;
8352
- };
8353
- function createFootball() {
8354
- const edges = [];
8355
- for (let y = 1; y <= 11; y++)
8356
- for (const x of [0, 8])
8357
- if (y < 11) edges.push(footballEdge(y * 9 + x, (y + 1) * 9 + x));
8358
- for (const y of [1, 11])
8359
- for (let x = 0; x < 8; x++)
8360
- if (x < 3 || x >= 5) edges.push(footballEdge(y * 9 + x, y * 9 + x + 1));
8361
- for (const y of [0, 12]) {
8362
- edges.push(
8363
- footballEdge(y * 9 + 3, y * 9 + 4),
8364
- footballEdge(y * 9 + 4, y * 9 + 5)
8365
- );
8366
- for (const x of [3, 5])
8367
- edges.push(footballEdge(y * 9 + x, (y === 0 ? 1 : 11) * 9 + x));
8368
- }
8369
- return { ball: 58, edges, turn: 0, winner: null, last: null };
8370
- }
8371
- function footballMoves(s) {
8372
- if (s.winner !== null) return [];
8373
- const [x, y] = footballPoint(s.ball), moves = [];
8374
- for (let dy = -1; dy <= 1; dy++)
8375
- for (let dx = -1; dx <= 1; dx++) {
8376
- const nx = x + dx, ny = y + dy, at = ny * 9 + nx;
8377
- if ((ny === 0 || ny === 12) && (x < 3 || x > 5)) continue;
8378
- if ((dx || dy) && nx >= 0 && nx <= 8 && ny >= 0 && ny <= 12 && valid(at) && !s.edges.includes(footballEdge(s.ball, at)))
8379
- moves.push(at);
8380
- }
8381
- return moves;
8382
- }
8383
- function playFootball(s, at) {
8384
- if (!footballMoves(s).includes(at))
8385
- throw Error("Choose an adjacent point along an unused line.");
8386
- const bounced = boundary(at) || s.edges.some((edge) => edge.split(":").map(Number).includes(at));
8387
- const y = Math.floor(at / 9);
8388
- const next3 = {
8389
- ...s,
8390
- ball: at,
8391
- edges: [...s.edges, footballEdge(s.ball, at)],
8392
- turn: bounced ? s.turn : s.turn === 0 ? 1 : 0,
8393
- winner: y === 0 ? 0 : y === 12 ? 1 : null,
8394
- last: [s.ball, at]
8395
- };
8396
- if (next3.winner === null && !footballMoves(next3).length)
8397
- next3.winner = 1 - next3.turn;
8398
- return next3;
8399
- }
8400
-
8401
- // lib/multiplayer/ludo.ts
8402
- function createLudo(count) {
8403
- if (!index(count - 2, 3)) throw Error("Choose two to four players.");
8404
- return {
8405
- pieces: Array.from({ length: count }, () => [-1, -1, -1, -1]),
8406
- turn: 0,
8407
- die: 0,
8408
- streak: 0,
8409
- attempts: 0,
8410
- winner: null,
8411
- last: "Roll a six to leave your yard."
8412
- };
8413
- }
8414
- var ludoTrack = (seat, progress, count = 4) => (seat * (count === 2 ? 20 : 10) + progress) % 40;
8415
- function ludoMoves(s) {
8416
- if (!s.die || s.winner !== null) return [];
8417
- return s.pieces[s.turn].flatMap((p, i) => {
8418
- const to = p < 0 ? s.die === 6 ? 0 : -1 : p + s.die;
8419
- return to >= 0 && to <= 43 && !s.pieces[s.turn].some((other, j) => j !== i && other === to) ? [i] : [];
8420
- });
8421
- }
8422
- function advance2(s) {
8423
- return {
8424
- ...s,
8425
- turn: (s.turn + 1) % s.pieces.length,
8426
- die: 0,
8427
- streak: 0,
8428
- attempts: 0
8429
- };
8430
- }
8431
- function playLudo(s, type, at, entropy2) {
8432
- if (s.winner !== null) throw Error("This match is finished.");
8433
- if (type === "roll") {
8434
- if (s.die) throw Error("Move a piece before rolling again.");
8435
- const die = 1 + Math.floor(draws(entropy2, 1)[0] * 6), streak = die === 6 ? s.streak + 1 : 0;
8436
- let next4 = {
8437
- ...s,
8438
- die,
8439
- streak,
8440
- attempts: s.attempts + 1,
8441
- last: `Player ${s.turn + 1} rolled ${die}.`
8442
- };
8443
- if (streak > 3)
8444
- return advance2({
8445
- ...next4,
8446
- last: "Three extra moves is the limit. Turn passes."
8447
- });
8448
- if (!ludoMoves(next4).length) {
8449
- const allHome = s.pieces[s.turn].every((p) => p === -1);
8450
- next4 = allHome && next4.attempts < 3 || die === 6 ? { ...next4, die: 0 } : advance2(next4);
8451
- }
8452
- return next4;
8453
- }
8454
- if (type !== "move" || at === void 0 || !index(at, 4) || !ludoMoves(s).includes(at))
8455
- throw Error(
8456
- "Choose a highlighted piece. You need an exact roll to reach home."
8457
- );
8458
- const pieces = s.pieces.map((row) => [...row]), old = pieces[s.turn][at], to = old < 0 ? 0 : old + s.die;
8459
- pieces[s.turn][at] = to;
8460
- if (to < 40) {
8461
- for (let seat = 0; seat < pieces.length; seat++)
8462
- if (seat !== s.turn) {
8463
- pieces[seat] = pieces[seat].map(
8464
- (p) => p >= 0 && p < 40 && ludoTrack(seat, p, pieces.length) === ludoTrack(s.turn, to, pieces.length) ? -1 : p
8465
- );
8466
- }
8467
- }
8468
- const won = pieces[s.turn].every((p) => p >= 40);
8469
- const next3 = {
8470
- ...s,
8471
- pieces,
8472
- die: 0,
8473
- attempts: 0,
8474
- winner: won ? s.turn : null,
8475
- last: `Player ${s.turn + 1} moved piece ${at + 1}.`
8476
- };
8477
- return s.die === 6 && s.streak < 3 || won ? next3 : advance2(next3);
8478
- }
8479
-
8480
8517
  // lib/multiplayer/go.ts
8481
8518
  function createGo(size = 9) {
8482
8519
  const board = Array(size * size).fill(0);
@@ -11208,7 +11245,7 @@ function playProperties(s, a, entropy2) {
11208
11245
  }
11209
11246
  if (s.phase === "tax") {
11210
11247
  if (a.type !== "tax" || ![0, 1].includes(a.amount ?? -1))
11211
- throw Error("Choose flat tax or ten percent of estate value.");
11248
+ throw Error("Choose the tax: amount 0 pays the flat 200, amount 1 pays ten percent of your estate value.");
11212
11249
  const estate = p.cash + s.assets.reduce(
11213
11250
  (n, a2, i) => n + (a2.owner === seat ? propertyFields[i].price + a2.houses * propertyFields[i].build : 0),
11214
11251
  0
@@ -11283,7 +11320,8 @@ function playProperties(s, a, entropy2) {
11283
11320
  t.dice = [];
11284
11321
  return t;
11285
11322
  }
11286
- throw Error("Choose an action for the current estate phase.");
11323
+ if (a.type === "release") throw Error(p.jail < 0 ? "You are not in jail." : "Leave jail at the start of your turn, in the roll phase: release with amount 0 pays 50, amount 1 uses a release card.");
11324
+ throw Error(`That action is not available in the ${s.phase} phase; choose one from the legal list.`);
11287
11325
  }
11288
11326
  function propertyView(s) {
11289
11327
  const { decks, ...publicState } = s;
@@ -11768,9 +11806,9 @@ function dominoMoves(s) {
11768
11806
  ].filter((m) => m !== null);
11769
11807
  });
11770
11808
  }
11771
- function endRound(s, winner2, bonus) {
11772
- const scores2 = [...s.scores];
11773
- if (winner2 !== null) scores2[winner2] += Math.round(bonus / 5) * 5;
11809
+ function endRound(s, winner2, bonus, out2 = false) {
11810
+ const scores2 = [...s.scores], points = Math.round(bonus / 5) * 5;
11811
+ if (winner2 !== null) scores2[winner2] += points;
11774
11812
  const finished = scores2.some((n) => n >= 100);
11775
11813
  return {
11776
11814
  ...s,
@@ -11778,7 +11816,7 @@ function endRound(s, winner2, bonus) {
11778
11816
  winner: finished ? best(scores2) : null,
11779
11817
  phase: finished ? "finished" : "round",
11780
11818
  turn: winner2 ?? s.turn,
11781
- result: winner2 === null ? "Blocked round: equal pip totals." : `Player ${winner2 + 1} wins the round.`
11819
+ result: winner2 === null ? "Blocked round: equal pip totals, nobody scores." : out2 ? `Player ${winner2 + 1} goes out and scores ${points} for the opponent's ${bonus} pips (rounded to five).` : `Blocked round: Player ${winner2 + 1} holds fewer pips and scores ${points} for the ${bonus}-pip difference (rounded to five).`
11782
11820
  };
11783
11821
  }
11784
11822
  var pipSum = (hand2) => hand2.reduce((n, [a, b]) => n + a + b, 0);
@@ -11797,8 +11835,9 @@ function playDomino(s, type, at, end, entropy2) {
11797
11835
  throw Error("Play a matching tile before drawing.");
11798
11836
  while (stock.length && !dominoMoves({ ...s, hands }).length)
11799
11837
  hands[s.turn].push(stock.pop());
11800
- if (dominoMoves({ ...s, hands }).length) return { ...s, hands, stock };
11801
- const next4 = { ...s, hands, stock, turn: 1 - s.turn, passes: s.passes + 1 };
11838
+ const drawn = hands[s.turn].length - s.hands[s.turn].length;
11839
+ if (dominoMoves({ ...s, hands }).length) return { ...s, hands, stock, result: `Player ${s.turn + 1} drew ${drawn} tile${drawn === 1 ? "" : "s"}.` };
11840
+ const next4 = { ...s, hands, stock, turn: 1 - s.turn, passes: s.passes + 1, result: `Player ${s.turn + 1} ${drawn ? `drew ${drawn} tile${drawn === 1 ? "" : "s"}, ` : ""}cannot play and passes.` };
11802
11841
  if (next4.passes < 2) return next4;
11803
11842
  const a2 = pipSum(hands[0]), b2 = pipSum(hands[1]);
11804
11843
  return endRound(next4, a2 === b2 ? null : a2 < b2 ? 0 : 1, Math.abs(a2 - b2));
@@ -11816,7 +11855,8 @@ function playDomino(s, type, at, end, entropy2) {
11816
11855
  }
11817
11856
  const left = chain[0], right = chain.at(-1);
11818
11857
  const total = chain.length === 1 ? left[0] + left[1] : (left[0] === left[1] ? left[0] * 2 : left[0]) + (right[0] === right[1] ? right[1] * 2 : right[1]);
11819
- if (total % 5 === 0) scores2[s.turn] += total;
11858
+ const scored = total % 5 === 0 && total > 0;
11859
+ if (scored) scores2[s.turn] += total;
11820
11860
  const next3 = {
11821
11861
  ...s,
11822
11862
  hands,
@@ -11824,10 +11864,11 @@ function playDomino(s, type, at, end, entropy2) {
11824
11864
  chain,
11825
11865
  scores: scores2,
11826
11866
  passes: 0,
11827
- turn: 1 - s.turn
11867
+ turn: 1 - s.turn,
11868
+ result: `Player ${s.turn + 1} played ${a}-${b}${scored ? ` and scores ${total}: the open ends total ${total}` : ""}.`
11828
11869
  };
11829
11870
  if (!hands[s.turn].length)
11830
- return endRound(next3, s.turn, pipSum(hands[1 - s.turn]));
11871
+ return endRound(next3, s.turn, pipSum(hands[1 - s.turn]), true);
11831
11872
  if (scores2.some((n) => n >= 100))
11832
11873
  return { ...next3, phase: "finished", winner: best(scores2) };
11833
11874
  return next3;
@@ -12139,7 +12180,7 @@ function agentListComplete(game, state, legal) {
12139
12180
  if (game === "poker") return !legal.some((action) => action.type === "raise");
12140
12181
  return false;
12141
12182
  }
12142
- var amounts = [.../* @__PURE__ */ new Set([...Array.from({ length: 301 }, (_, i) => i), ...Array.from({ length: 41 }, (_, i) => 300 + i * 10)])];
12183
+ var amounts = [.../* @__PURE__ */ new Set([...Array.from({ length: 301 }, (_, i) => i), ...Array.from({ length: 141 }, (_, i) => 300 + i * 5)])];
12143
12184
  var promotions = ["marriage", "marriage-claim", "makao", "normal", "schneider", "schwarz", "ouvert"];
12144
12185
  function hand(state, seat) {
12145
12186
  for (const key2 of ["hands", "hand"]) {
@@ -12166,6 +12207,15 @@ function cardGroups(cards) {
12166
12207
  else for (let i = 0; i + 4 <= cards.length; i += 4) add(cards.slice(i, i + 4));
12167
12208
  return [...groups.values()].map(([group]) => group);
12168
12209
  }
12210
+ function runGroups(cards) {
12211
+ const runs = [];
12212
+ for (const card2 of cards) for (let length = 3; ; length++) {
12213
+ const run = Array.from({ length }, (_, i) => card2 + i);
12214
+ if (card2 % 13 + length > 13 || !run.every((c) => cards.includes(c))) break;
12215
+ runs.push(run);
12216
+ }
12217
+ return runs;
12218
+ }
12169
12219
  var atTypes = /* @__PURE__ */ new Set(["fire", "sow", "hold", "score", "capture", "knock", "dead", "mortgage", "redeem", "build", "sell", "choose", "kitty", "place", "tile", "move"]);
12170
12220
  var amountTypes = /* @__PURE__ */ new Set(["bid", "raise", "declare", "tax", "release", "moon", "trump", "contract", "kitty", "announce", "choose", "tile", "place", "move"]);
12171
12221
  var cardTypes = /* @__PURE__ */ new Set(["play", "discard", "exchange", "select", "meld", "layoff"]);
@@ -12220,6 +12270,10 @@ function specific(game, state, seat) {
12220
12270
  { type: "play", cards, promotion: "makao" },
12221
12271
  ...[0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13].flatMap((amount) => [{ type: "play", cards, amount }, { type: "play", cards, amount, promotion: "makao" }])
12222
12272
  ])];
12273
+ if (game === "rummy") {
12274
+ const runs = runGroups(hand(state, seat));
12275
+ return [...generic(adapters.rummy, state, seat), ...runs.map((cards) => ({ type: "meld", cards })), ...runs.flatMap((cards) => Array.from({ length: 20 }, (_, at) => ({ type: "layoff", at, cards })))];
12276
+ }
12223
12277
  if (game === "go") {
12224
12278
  const s = state;
12225
12279
  if (s.phase === "scoring" && s.accepted.includes(seat)) return [];
@@ -12429,12 +12483,33 @@ function agentPosition(room) {
12429
12483
  const active = room.phase === "playing" && !!match && !room.players[room.seat].left && !ended && !room.players.some((p) => p.paused);
12430
12484
  const grid = !complete && match ? indexedBoard(room.game, match.state) : null;
12431
12485
  const wire = !complete && match && enumeratedAgentGames.includes(room.game) ? active ? agentLegalActions(room.game, match.state, room.seat, room.capacity) ?? [] : [] : null;
12432
- const checkerLast = match?.kind === "checkers" || match?.kind === "brazilian-checkers" ? checkerLastMove(match.state.history) : null;
12486
+ const checkerLast = match?.kind === "checkers" ? checkerLastMove(match.state.history) : null;
12487
+ const snapshotLast = (() => {
12488
+ if (!match) return null;
12489
+ const draughts2 = (key2) => key2?.split(":")[0].split(",").map(Number);
12490
+ const pieceOwner = (p) => p ? Math.abs(p) - 1 : -1, seatOwner = (p) => p;
12491
+ if (match.kind === "checkers" || match.kind === "brazilian-checkers" || match.kind === "international-draughts") {
12492
+ const [a, b] = match.state.history.slice(-2);
12493
+ return snapshotTurn(draughts2(a), draughts2(b), pieceOwner, [1 - Number(b?.split(":")[1])]);
12494
+ }
12495
+ if (match.kind === "fanorona") {
12496
+ const [a, b] = match.state.history.slice(-2).map((k) => k.split(":")[0].match(/-1|0|1/g)?.map(Number));
12497
+ return snapshotTurn(a, b, seatOwner, [1 - match.state.turn, match.state.turn]);
12498
+ }
12499
+ if (match.kind === "go") {
12500
+ if (match.state.passes > 0) return { by_seat: 1 - match.state.turn, type: "pass" };
12501
+ const [a, b] = match.state.history.slice(-2).map((k) => [...k].map(Number));
12502
+ return snapshotTurn(a, b, (p) => p - 1, [1 - match.state.turn]);
12503
+ }
12504
+ if ((match.kind === "amazons" || match.kind === "dots-boxes" || match.kind === "lines-of-action" || match.kind === "backgammon") && match.state.last) return match.state.last;
12505
+ return null;
12506
+ })();
12433
12507
  const dotsLabeled = match?.kind === "dots-boxes" ? match.state.boxes.map((owner4, box) => {
12434
12508
  const { size, edges } = match.state, sides2 = boxEdges(size, box), drawn = sides2.filter((e) => edges[e] >= 0).length;
12435
12509
  const named = ["top", "bottom", "left", "right"].map((side2, i) => `${side2} ${sides2[i] + 1}${edges[sides2[i]] >= 0 ? "*" : ""}`).join(", ");
12436
12510
  return `box row ${Math.floor(box / size) + 1} col ${box % size + 1} [${owner4 < 0 ? "." : "XO"[owner4]}]: ${named}${owner4 < 0 && drawn === 3 ? " (three sides: takeable)" : ""}`;
12437
12511
  }) : null;
12512
+ const ludoLabeled = match?.kind === "ludo" ? match.state.pieces.map((row, seat) => `seat ${seat} (${room.players[seat]?.name ?? "empty"}): ${row.map((p, i) => `piece ${i + 1} ${p < 0 ? "yard" : p >= 40 ? `home lane ${p - 39}/4` : `progress ${p}, track square ${ludoTrack(seat, p, match.state.pieces.length)}`}`).join("; ")}`) : null;
12438
12513
  const ginHand = match?.kind === "gin-rummy" && match.state.phase !== "finished" ? match.state.hands[room.seat]?.filter((c) => c !== null) ?? [] : [];
12439
12514
  const ginHint = ginHand.length ? (() => {
12440
12515
  const layout = ginLayout(ginHand), names = (cards) => cards.map((c) => `${cardLabel(c)} (id ${c})`);
@@ -12447,7 +12522,10 @@ function agentPosition(room) {
12447
12522
  knock_limit: 10
12448
12523
  };
12449
12524
  })() : null;
12450
- const ruleNotes = match?.kind === "thousand" ? match.state.scores.flatMap((score, seat) => match.state.phase !== "finished" && score >= 800 ? [`Seat ${seat} (${room.players[seat]?.name ?? "empty"}) has ${score} points: from 800, defending points no longer score; only a made contract adds points.`] : []) : [];
12525
+ const ruleNotes = match?.kind === "thousand" ? match.state.scores.flatMap((score, seat) => match.state.phase !== "finished" && score >= 800 ? [`Seat ${seat} (${room.players[seat]?.name ?? "empty"}) has ${score} points: from 800, defending points no longer score; only a made contract adds points.`] : []) : match?.kind === "dice" ? match.state.sheets.map((sheet, seat) => {
12526
+ const upper = sheet.slice(0, 6).reduce((n, v) => n + (v ?? 0), 0);
12527
+ return `Seat ${seat} (${room.players[seat]?.name ?? "empty"}): upper section ${upper}/63${upper >= 63 ? ", 35 bonus earned" : ""}; total ${diceTotal(match.state, seat)}.`;
12528
+ }) : match?.kind === "polish-hearts" && match.state.phase !== "finished" ? [polishHandNote(match.state.round, match.state.hands.length)] : match?.kind === "three-check" ? [`Checks given: seat 0 ${match.state.checks[0]}, seat 1 ${match.state.checks[1]}; the third check wins.`] : [];
12451
12529
  const sixtyUnseen = match?.kind === "sixty-six" && match.state.phase === "play" ? (() => {
12452
12530
  const s = match.state, seen = /* @__PURE__ */ new Set([...(s.hands[room.seat] ?? []).filter((c) => c !== null), ...s.played ?? [], ...s.trick.map((c) => c.card), ...s.upcard === null ? [] : [s.upcard]]);
12453
12531
  return Array.from({ length: 24 }, (_, i) => Math.floor(i / 6) * 13 + 7 + i % 6).filter((c) => !seen.has(c)).map((c) => `${cardLabel(c)} (id ${c})`);
@@ -12471,10 +12549,10 @@ function agentPosition(room) {
12471
12549
  players: room.players.map((p, seat) => ({ seat, name: p.name, left: p.left, ...p.agent ? { agent: true } : {}, ...p.paused ? { paused: true } : {} })),
12472
12550
  board_size: complete ? { rows, columns } : grid ? { rows: grid.rows, columns: grid.columns } : { rows: 0, columns: 0 },
12473
12551
  board_top_to_bottom: board,
12474
- board_labeled: dotsLabeled ?? (match?.kind === "oware" ? [...labeledBoard(room.game, board) ?? [], `captured: seat 0 ${match.state.scores[0]}, seat 1 ${match.state.scores[1]}`] : null) ?? labeledBoard(room.game, board) ?? grid?.labeled ?? null,
12552
+ board_labeled: dotsLabeled ?? ludoLabeled ?? (match?.kind === "football" ? footballLabeled(match.state) : null) ?? (match?.kind === "oware" ? [...labeledBoard(room.game, board) ?? [], `captured: seat 0 ${match.state.scores[0]}, seat 1 ${match.state.scores[1]}`] : null) ?? labeledBoard(room.game, board) ?? grid?.labeled ?? null,
12475
12553
  ...grid ? { board_legend: grid.legend } : dotsLabeled ? { board_legend: "One line per box: owner (X seat 0, O seat 1, . open), then its four edges as one-based edge numbers (--edge N); * marks a drawn edge. A box with three drawn edges can be taken now." } : {},
12476
12554
  // Chess and its variants record the last move in SAN.
12477
- last_move: (match?.kind === "chess" || match?.kind === "atomic-chess" || match?.kind === "three-check") && match.state.last ? { san: match.state.last } : checkerLast ? { from: square(checkerLast.from), to: square(checkerLast.to) } : cribbageLast ? cribbageLast : match?.kind === "makao" && match.state.last ? { by_seat: match.state.last.seat, type: match.state.last.type, ...match.state.last.cards ? { cards: match.state.last.cards.map((c) => `${cardLabel(c)} (id ${c})`) } : {}, ...match.state.last.count !== void 0 && match.state.last.type !== "play" ? { count: match.state.last.count } : {}, ...match.state.last.announced ? { announced_makao: true } : {} } : asianLast ? { by_seat: asianLast.by, ...asianLast.from === null ? { type: "drop" } : { from: asianLast.from }, to: asianLast.to } : match?.kind === "oware" && match.state.last !== null ? { last_seed_in_pit: match.state.last + 1 } : millLast ? { by_seat: millLast.seat, ...millLast.from === null ? { type: "place" } : { type: "move", from: millLast.from }, at: millLast.to, ...millLast.captured.length ? { captured: millLast.captured } : {} } : last === null ? null : room.game === "connect-four" ? { column: last % columns + 1, row_from_top: Math.floor(last / columns) + 1 } : { row: Math.floor(last / columns) + 1, column: last % columns + 1 },
12555
+ last_move: (match?.kind === "chess" || match?.kind === "atomic-chess" || match?.kind === "three-check") && match.state.last ? { san: match.state.last } : checkerLast ? { by_seat: snapshotLast?.by_seat, from: square(checkerLast.from), to: square(checkerLast.to), ...snapshotLast && "captured" in snapshotLast && Array.isArray(snapshotLast.captured) ? { captured: snapshotLast.captured.map(square) } : {} } : snapshotLast ? snapshotLast : cribbageLast ? cribbageLast : match?.kind === "makao" && match.state.last ? { by_seat: match.state.last.seat, type: match.state.last.type, ...match.state.last.cards ? { cards: match.state.last.cards.map((c) => `${cardLabel(c)} (id ${c})`) } : {}, ...match.state.last.count !== void 0 && match.state.last.type !== "play" ? { count: match.state.last.count } : {}, ...match.state.last.announced ? { announced_makao: true } : {} } : asianLast ? { by_seat: asianLast.by, ...asianLast.from === null ? { type: "drop" } : { from: asianLast.from }, to: asianLast.to } : match?.kind === "oware" && match.state.last !== null ? { last_seed_in_pit: match.state.last + 1 } : millLast ? { by_seat: millLast.seat, ...millLast.from === null ? { type: "place" } : { type: "move", from: millLast.from }, at: millLast.to, ...millLast.captured.length ? { captured: millLast.captured } : {} } : last === null ? null : room.game === "connect-four" ? { column: last % columns + 1, row_from_top: Math.floor(last / columns) + 1 } : { row: Math.floor(last / columns) + 1, column: last % columns + 1 },
12478
12556
  ...room.game === "chess" ? { fen } : {},
12479
12557
  // The side to move is in check (chess family, xiangqi, shogi).
12480
12558
  ...match && room.phase === "playing" && (match.kind === "chess" ? chessPosition(match.state).inCheck() : "check" in match.state && match.state.check === true) ? { in_check: true } : {},
@@ -12510,13 +12588,29 @@ function labeledBoard(game, board) {
12510
12588
  ...board.map((row, r) => `${String(algebraic ? board.length - r : r + 1).padStart(rowWidth)} ${game === "hex" ? " ".repeat(Math.round(r * (cell + 1) / 2)) : ""}${[...row].map((p) => p.padStart(cell)).join(" ")}`)
12511
12589
  ];
12512
12590
  }
12591
+ function moveRejection(position, input) {
12592
+ if (!position.turn?.yours) return "It is not your turn. ";
12593
+ if (input.from === void 0) return "";
12594
+ const wire = "legal_wire_actions" in position ? position.legal_wire_actions ?? [] : [];
12595
+ const moves = [...position.legal_actions, ...wire].filter((a) => a.type === "move" && String(a.from) === input.from);
12596
+ if (!moves.length) return `No legal move starts on ${input.from}. `;
12597
+ const exact = moves.filter((a) => String(a.to) === input.to);
12598
+ if (exact.length) return `${input.from} to ${input.to} is listed with promotion ${[...new Set(exact.map((a) => String(a.promotion ?? "none")))].join(" or ")}; pass --promotion exactly as listed. `;
12599
+ return `${input.from} to ${input.to} is not legal now; from ${input.from} the legal destinations are ${[...new Set(moves.map((a) => String(a.to)))].join(", ")}. `;
12600
+ }
12601
+ function polishHandNote(round, count) {
12602
+ const three = count === 3, total = 8 + count;
12603
+ const hands = [`no tricks: ${three ? 15 : 20} off per trick taken`, "no hearts: 20 off per heart", "no queens: 60 off per queen", "no jacks or kings: 30 off each", `no king of hearts: ${three ? 140 : 150} off`, `no seventh or last trick: ${three ? 70 : 75} off each`, "robber: every penalty of hands 1\u20136 at once"];
12604
+ const name = round < 7 ? hands[round] : round < 7 + count ? `trump contract chosen by the dealer: each trick taken scores +${three ? 20 : 25}` : "layout: build the four suit rows outward from the base rank (scoring as in the rules)";
12605
+ return `Hand ${round + 1} of ${total}: ${name}.`;
12606
+ }
12513
12607
  function agentMove(position, input) {
12514
12608
  const action = position.legal_actions.find((a) => a.type === "swap" ? input.swap === true && Object.entries(input).every(([k, v]) => k === "swap" || v === void 0) : a.type === "edge" ? input.edge === a.edge && Object.entries(input).every(([k, v]) => k === "edge" || v === void 0) : a.type === "sow" ? input.pit === a.pit && Object.entries(input).every(([k, v]) => k === "pit" || v === void 0) : !input.swap && input.edge === void 0 && input.pit === void 0 && a.type === "move" ? input.column === void 0 && input.row === void 0 && a.from === input.from && a.to === input.to && a.promotion === input.promotion : a.type === "place" || a.type === "drop" ? !input.swap && input.edge === void 0 && input.pit === void 0 && input.from === void 0 && input.to === void 0 && input.promotion === void 0 && a.column === input.column && (a.type === "drop" ? input.row === void 0 : a.row === input.row) : false);
12515
12609
  if (!action && input.from !== void 0 && input.to !== void 0 && "legal_wire_actions" in position) {
12516
12610
  const wire = position.legal_wire_actions?.find((a) => a.type === "move" && String(a.from) === input.from && String(a.to) === input.to && (a.promotion === void 0 || a.promotion === input.promotion));
12517
12611
  if (wire) return wire;
12518
12612
  }
12519
- if (!action) throw Error("Choose an exact entry from legal_actions on your turn. Use --column for drops, --row/--column for placements, or --from/--to with explicit --promotion when listed.");
12613
+ if (!action) throw Error(`${moveRejection(position, input)}Choose an exact entry from legal_actions on your turn. Use --column for drops, --row/--column for placements, or --from/--to with explicit --promotion when listed.`);
12520
12614
  if (action.type === "swap") return { type: "swap" };
12521
12615
  if (action.type === "edge") return { type: "place", at: action.edge - 1 };
12522
12616
  if (action.type === "sow") return { type: "sow", at: action.pit - 1 };
@@ -12558,6 +12652,10 @@ function agentWireMove(position, json) {
12558
12652
  if (!position.action_types.includes(action.type)) throw Error(`Choose a wire action type (${position.action_types.join(", ")}) from game rules, or pass an exact legal_actions entry.`);
12559
12653
  if (position.phase !== "playing" || position.you.left || position.paused) throw Error("This match is not active for your seat.");
12560
12654
  if (position.game === "go" && action.type === "accept" && action.scoreKey === void 0 && position.visible_state) action = { ...action, scoreKey: goProposalKey(position.visible_state) };
12655
+ if (position.game === "categories" && ["submit", "stop", "vote", "expire"].includes(action.type) && position.visible_state) {
12656
+ const state = position.visible_state;
12657
+ action = { ...action, at: action.at ?? state.round, scoreKey: action.scoreKey ?? state.roundKey ?? void 0, ...Array.isArray(action.text) ? { text: JSON.stringify(action.text) } : {} };
12658
+ }
12561
12659
  return action;
12562
12660
  }
12563
12661
  function sameFields(a, b) {
@@ -12811,7 +12909,9 @@ function nextStep(position, event, brief = false, server) {
12811
12909
  const room = `--room ${position.room}${server && server !== defaultServer ? ` --server ${server}` : ""}`, revision = position.revision, flag = brief ? " --brief" : "";
12812
12910
  const wait = `leafarcade game wait ${room} --after-revision ${revision} --timeout ${defaultWaitSeconds}${flag}`;
12813
12911
  if (position.you.left) return "You left this room. Create or join another room only if the human asks.";
12814
- if (position.phase === "finished") return `Match finished${position.outcome?.label ? ` (${position.outcome.label})` : ""}. Tell the human the result. Rematch only if they ask: leafarcade game rematch ${room} --revision ${revision}.`;
12912
+ const won = position.outcome?.winner;
12913
+ const result = won === void 0 || won === null ? position.phase === "finished" ? " Nobody won." : "" : won === position.you.seat ? " You won." : ` Seat ${won} (${position.players[won]?.name ?? "empty"}) won.`;
12914
+ if (position.phase === "finished") return `Match finished${position.outcome?.label ? ` (${position.outcome.label})` : ""}.${result} Tell the human the result. Rematch only if they ask: leafarcade game rematch ${room} --revision ${revision}.`;
12815
12915
  if (position.paused) return `Paused. Stop and tell the human. Resume only when asked: leafarcade game resume ${room} --revision ${revision} (each player who paused must resume).`;
12816
12916
  if (position.phase === "waiting") {
12817
12917
  const host = position.you.seat === 0, seated = position.players.filter((p) => !p.left).length;
@@ -12935,7 +13035,7 @@ async function installSkill(agent = "codex", update = false) {
12935
13035
  // packages/cli/package.json
12936
13036
  var package_default = {
12937
13037
  name: "@leafarcade/cli",
12938
- version: "0.9.0",
13038
+ version: "0.9.1",
12939
13039
  private: false,
12940
13040
  description: "Create private browser board games and play with your AI agent. Includes 53 games and an installable Codex/Claude skill.",
12941
13041
  type: "module",
@@ -13043,13 +13143,13 @@ var examples = {
13043
13143
  amazons: `--action '{"type":"move","from":30,"to":33,"amount":53}'`,
13044
13144
  "lines-of-action": `--action '{"type":"move","from":1,"to":17}'`,
13045
13145
  "last-greenhouse": `--action '{"type":"share"}'`,
13046
- euchre: "pass | trump --amount 0..3 [promotion alone] | discard/play --at CARD_ID | next. Use legal wire actions; card IDs use 24-card suit*6+rank encoding.",
13146
+ euchre: `--action '{"type":"trump","amount":2}'`,
13047
13147
  cribbage: `--action '{"type":"discard","cards":[0,1]}'`,
13048
13148
  fanorona: `--action '{"type":"move","from":13,"to":22,"amount":0}'`,
13049
- categories: '{"type":"begin","promotion":"untimed"}',
13050
- cheat: '{"type":"play","cards":[0]}',
13051
- president: '{"type":"play","cards":[1]}',
13052
- rummy: '{"type":"stock"}',
13149
+ categories: `--action '{"type":"begin","promotion":"untimed"}'`,
13150
+ cheat: `--action '{"type":"play","cards":[0]}'`,
13151
+ president: `--action '{"type":"play","cards":[1]}'`,
13152
+ rummy: `--action '{"type":"stock"}'`,
13053
13153
  "connect-four": "--column 4",
13054
13154
  chess: "--from e2 --to e4",
13055
13155
  checkers: "--from b6 --to a5",
@@ -13712,6 +13812,27 @@ var scores = (position) => {
13712
13812
  return Array.isArray(value) && value.length && value.every((n) => typeof n === "number") ? value : null;
13713
13813
  };
13714
13814
  var withoutLegal = (state) => Object.fromEntries(Object.entries(state).filter(([key2]) => key2 !== "legalActions"));
13815
+ function groupedLegal(list) {
13816
+ if (list.length <= 80 || !list.every((a) => a && typeof a === "object")) return null;
13817
+ const groups = /* @__PURE__ */ new Map();
13818
+ for (const action of list) {
13819
+ const keys = Object.keys(action), last = keys.at(-1);
13820
+ if (keys.length < 2 || last === "type") return null;
13821
+ const head = Object.fromEntries(keys.slice(0, -1).map((k) => [k, action[k]])), id = JSON.stringify(head) + last;
13822
+ const group = groups.get(id) ?? { ...head, [`${last}_choices`]: [] };
13823
+ group[`${last}_choices`].push(action[last]);
13824
+ groups.set(id, group);
13825
+ }
13826
+ return [...groups.values()].map((group) => {
13827
+ const [name, choices] = Object.entries(group).find(([k]) => k.endsWith("_choices"));
13828
+ const numbers = choices.every((n) => Number.isInteger(n)) ? choices.toSorted((a, b) => a - b) : null;
13829
+ if (!numbers || numbers.length < 20 || numbers.some((n, i) => i && n !== numbers[i - 1] + 1)) return group;
13830
+ const { [name]: _list, ...rest } = group;
13831
+ void _list;
13832
+ return { ...rest, [name.replace(/_choices$/, "_range")]: [numbers[0], numbers.at(-1)] };
13833
+ });
13834
+ }
13835
+ var groupNote = "Grouped list: each entry gives the choices for its last field (*_choices, or *_range [min, max] inclusive). Send one action with a single value, e.g. column 4, not the list.";
13715
13836
  function briefPosition(position) {
13716
13837
  const turn = position.turn ? position.turn.yours ? "yours" : seatName(position, position.turn.seat) : null;
13717
13838
  const wire = "legal_wire_actions" in position ? position.legal_wire_actions ?? [] : null;
@@ -13734,7 +13855,8 @@ function briefPosition(position) {
13734
13855
  board_labeled: position.board_labeled,
13735
13856
  last_move: position.last_move,
13736
13857
  ..."changed_since_last_read" in position ? { changed_since_last_read: position.changed_since_last_read } : {},
13737
- ...wire ? { legal_wire_actions: wire, legal_wire_actions_complete: !!position.legal_wire_actions_complete } : { legal_actions: position.legal_actions, legal_actions_complete: position.legal_actions_complete },
13858
+ ...wire ? { legal_wire_actions: groupedLegal(wire) ?? wire, legal_wire_actions_complete: !!position.legal_wire_actions_complete } : { legal_actions: groupedLegal(position.legal_actions) ?? position.legal_actions, legal_actions_complete: position.legal_actions_complete },
13859
+ ...groupedLegal(wire ?? position.legal_actions) ? { legal_actions_grouped: groupNote } : {},
13738
13860
  // Card, tile and other games without a labeled board still need their seat-visible state.
13739
13861
  ..."deadline_seconds_left" in position ? { deadline_seconds_left: position.deadline_seconds_left } : {},
13740
13862
  ..."board_legend" in position ? { board_legend: position.board_legend } : {},
@@ -13843,8 +13965,10 @@ try {
13843
13965
  const goSize = integer(values["go-size"] ?? "9", "go-size", 9, 19);
13844
13966
  if (goSize !== 9 && goSize !== 13 && goSize !== 19) throw Error("go-size must be 9, 13 or 19.");
13845
13967
  result = await createRoom({ server, site: values.site ?? "https://leafarcade.com", game: values.game ?? "connect-four", name: values.name, capacity: values.capacity === void 0 ? void 0 : integer(values.capacity, "capacity", 2, 8), goSize, open: values.open, access: values.access, allowSpectators: !values["no-spectators"], brief: values.brief }, controller.signal);
13846
- } else if (command === "rules") result = agentRules(values.game);
13847
- else {
13968
+ } else if (command === "rules") {
13969
+ if (!values.game) throw Error("Pass --game GAME_ID (for example: leafarcade game rules --game thousand). leafarcade --help lists the game IDs.");
13970
+ result = agentRules(values.game);
13971
+ } else {
13848
13972
  const server = serviceUrl(values.server ?? defaultServer);
13849
13973
  if (command === "join" ? !positionals[1] : !values.room) throw Error(command === "join" ? 'Missing ROOMCODE. Usage: leafarcade join ROOMCODE --name "My agent"' : `Missing --room ROOMCODE. Usage: leafarcade game ${command} --room ROOMCODE. Saved rooms: leafarcade rooms.`);
13850
13974
  const room = roomCode(command === "join" ? positionals[1] : values.room);
@@ -13918,6 +14042,8 @@ try {
13918
14042
  const row = values.row === void 0 ? void 0 : integer(values.row, "row", 1, position.board_size.rows);
13919
14043
  if (revision !== position.revision) throw new ServiceError(`The room changed (now revision ${position.revision}). Read game status before playing.`, "STALE_REVISION");
13920
14044
  if (values.action && [values.column, values.row, values.from, values.to, values.promotion, values.edge, values.pit, values.swap].some((v) => v !== void 0)) throw Error("Use --action or convenience flags, not both.");
14045
+ const grouped = values.action?.match(/"(\w+)_(?:choices|range)"/);
14046
+ if (grouped) throw Error(`That entry is from a grouped list: replace the list with "${grouped[1]}" and one value from it.`);
13921
14047
  const action = values.action ? agentWireMove(position, values.action) : agentMove(position, { column, row, from: values.from, to: values.to, promotion: values.promotion, swap: values.swap, edge: values.edge === void 0 ? void 0 : integer(values.edge, "edge", 1, 1e3), pit: values.pit === void 0 ? void 0 : integer(values.pit, "pit", 1, 12) });
13922
14048
  const waitSeconds = integer(values.timeout ?? String(defaultWaitSeconds), "timeout", 1, 3600);
13923
14049
  await rpc(session, "play", { revision, action }, false, controller.signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leafarcade/cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": false,
5
5
  "description": "Create private browser board games and play with your AI agent. Includes 53 games and an installable Codex/Claude skill.",
6
6
  "type": "module",
@@ -24,7 +24,7 @@ leafarcade game create --game connect-four --name "My agent" # add --open on
24
24
 
25
25
  Every JSON response has a `next` field with the exact command to run next. Follow it.
26
26
 
27
- Add `--brief` to `status`, `wait`, `start` and `play`. It returns only `status` (room, revision, phase, your piece, whose turn, outcome), `board_labeled`, `last_move`, the legal moves and `next`, which keeps the flag. Games without a labeled board (cards, tiles) also get `visible_state`. Drop `--brief` once if you need the full position: player list, raw state or rules.
27
+ Add `--brief` to `status`, `wait`, `start` and `play`. It returns only `status` (room, revision, phase, your piece, whose turn, outcome), `board_labeled`, `last_move`, the legal moves and `next`, which keeps the flag. Games without a labeled board (cards, tiles) also get `visible_state`. Long move lists (more than 80, such as Gomoku or Amazons) are grouped in `--brief`: `{"type":"place","row":3,"column_choices":[1,2,5]}` means row 3 with any of those columns; send one value (`--row 3 --column 2`). Drop `--brief` once if you need the full position: player list, raw state or rules.
28
28
 
29
29
  `leafarcade help games` lists game IDs. `--capacity N` picks a supported table size; the default is the game's minimum. Each additional agent needs its own `LEAFARCADE_CONFIG_DIR` and seat.
30
30
 
@@ -35,7 +35,7 @@ When the human already has a room code, use `leafarcade join ROOMCODE --name "My
35
35
  ## The play loop
36
36
 
37
37
  - **Wait on the table yourself.** After your move, use `play --wait` or `game wait`. Never ask the human to tell you they moved.
38
- - Read `game rules --game GAME_ID` once. On your turn, `board_labeled` shows the board with the same row/column numbers as the move flags, and `last_move` shows the opponent's last move in board games (null in most card games; read `changed_since_last_read` there). Some games add hints: `rule_notes` (rules in force now, such as Thousand's 800 rule), `gin_deadwood` and `sixty_six_unseen` (cards you have not seen this deal).
38
+ - Read `game rules --game GAME_ID` once. On your turn, `board_labeled` shows the board with the same row/column numbers as the move flags, and `last_move` shows the latest move in board games with `by_seat` and any `captured` squares (null in most card games; read `changed_since_last_read` there). In `legal_actions`, rows, columns, pits and edges count from 1, as the move flags do; the zero-based `at` in the help is the wire form the CLI converts to. Some games add hints: `rule_notes` (rules in force now, such as Thousand's 800 rule, three-check counts or the Dice upper bonus), `gin_deadwood` and `sixty_six_unseen` (cards you have not seen this deal).
39
39
  - Before each move in line games (Connect Four, tic-tac-toe, gomoku, hex), check two things across every row, column and **both diagonals**. First, can you win now? Second, does the opponent threaten to win next turn?
40
40
  - Most games list every legal move. With `legal_actions_complete` or `legal_wire_actions` (complete unless `legal_wire_actions_complete` is false), choose a listed entry and pass it unchanged: `--action '{"type":"bid","amount":120}'`. A partial list (melds, trade offers, raise amounts, bluffs) shows examples; build other moves from `game rules`. Wire indexes are zero-based; the convenience flags are one-based.
41
41
  - Card games add `cards_labeled` (for example `"hands.1": ["Q♠ (id 49)", …]`): read card names there, not from raw IDs. Index boards (Go, shogi, draughts, Amazons) label rows with the index of their first cell, so `at` = row label + column offset; `board_legend` explains the symbols.