@journey-to-code/games 0.0.0-stage → 0.1.0

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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/dist/backgammon.d.ts +146 -0
  3. package/dist/backgammon.js +1221 -0
  4. package/dist/backgammon.js.map +1 -0
  5. package/dist/blackjack.d.ts +66 -0
  6. package/dist/blackjack.js +452 -0
  7. package/dist/blackjack.js.map +1 -0
  8. package/dist/bluff.d.ts +60 -0
  9. package/dist/bluff.js +239 -0
  10. package/dist/bluff.js.map +1 -0
  11. package/dist/board.d.ts +387 -0
  12. package/dist/board.js +105 -0
  13. package/dist/board.js.map +1 -0
  14. package/dist/bridge.d.ts +86 -0
  15. package/dist/bridge.js +494 -0
  16. package/dist/bridge.js.map +1 -0
  17. package/dist/canasta.d.ts +88 -0
  18. package/dist/canasta.js +646 -0
  19. package/dist/canasta.js.map +1 -0
  20. package/dist/checkers.d.ts +108 -0
  21. package/dist/checkers.js +756 -0
  22. package/dist/checkers.js.map +1 -0
  23. package/dist/chess.d.ts +162 -0
  24. package/dist/chess.js +904 -0
  25. package/dist/chess.js.map +1 -0
  26. package/dist/chesscore.d.ts +136 -0
  27. package/dist/chesscore.js +1121 -0
  28. package/dist/chesscore.js.map +1 -0
  29. package/dist/combat.d.ts +102 -0
  30. package/dist/combat.js +772 -0
  31. package/dist/combat.js.map +1 -0
  32. package/dist/contractrummy.d.ts +77 -0
  33. package/dist/contractrummy.js +430 -0
  34. package/dist/contractrummy.js.map +1 -0
  35. package/dist/crazy8.d.ts +65 -0
  36. package/dist/crazy8.js +366 -0
  37. package/dist/crazy8.js.map +1 -0
  38. package/dist/cribbage.d.ts +75 -0
  39. package/dist/cribbage.js +428 -0
  40. package/dist/cribbage.js.map +1 -0
  41. package/dist/cuckoo.d.ts +43 -0
  42. package/dist/cuckoo.js +216 -0
  43. package/dist/cuckoo.js.map +1 -0
  44. package/dist/drinkingpyramid.d.ts +54 -0
  45. package/dist/drinkingpyramid.js +213 -0
  46. package/dist/drinkingpyramid.js.map +1 -0
  47. package/dist/drinks.d.ts +25 -0
  48. package/dist/drinks.js +40 -0
  49. package/dist/drinks.js.map +1 -0
  50. package/dist/dropfour.d.ts +103 -0
  51. package/dist/dropfour.js +577 -0
  52. package/dist/dropfour.js.map +1 -0
  53. package/dist/durak.d.ts +58 -0
  54. package/dist/durak.js +332 -0
  55. package/dist/durak.js.map +1 -0
  56. package/dist/euchre.d.ts +80 -0
  57. package/dist/euchre.js +453 -0
  58. package/dist/euchre.js.map +1 -0
  59. package/dist/fivedraw.d.ts +50 -0
  60. package/dist/fivedraw.js +289 -0
  61. package/dist/fivedraw.js.map +1 -0
  62. package/dist/fivehundred.d.ts +90 -0
  63. package/dist/fivehundred.js +504 -0
  64. package/dist/fivehundred.js.map +1 -0
  65. package/dist/freecell.d.ts +50 -0
  66. package/dist/freecell.js +327 -0
  67. package/dist/freecell.js.map +1 -0
  68. package/dist/fruitsalad.d.ts +69 -0
  69. package/dist/fruitsalad.js +427 -0
  70. package/dist/fruitsalad.js.map +1 -0
  71. package/dist/gin.d.ts +72 -0
  72. package/dist/gin.js +393 -0
  73. package/dist/gin.js.map +1 -0
  74. package/dist/go.d.ts +132 -0
  75. package/dist/go.js +659 -0
  76. package/dist/go.js.map +1 -0
  77. package/dist/goboard.d.ts +218 -0
  78. package/dist/goboard.js +880 -0
  79. package/dist/goboard.js.map +1 -0
  80. package/dist/gofish.d.ts +37 -0
  81. package/dist/gofish.js +235 -0
  82. package/dist/gofish.js.map +1 -0
  83. package/dist/golf.d.ts +64 -0
  84. package/dist/golf.js +343 -0
  85. package/dist/golf.js.map +1 -0
  86. package/dist/handfoot.d.ts +86 -0
  87. package/dist/handfoot.js +515 -0
  88. package/dist/handfoot.js.map +1 -0
  89. package/dist/hearts.d.ts +65 -0
  90. package/dist/hearts.js +390 -0
  91. package/dist/hearts.js.map +1 -0
  92. package/dist/holdem.d.ts +45 -0
  93. package/dist/holdem.js +286 -0
  94. package/dist/holdem.js.map +1 -0
  95. package/dist/horserace.d.ts +49 -0
  96. package/dist/horserace.js +207 -0
  97. package/dist/horserace.js.map +1 -0
  98. package/dist/index.d.ts +65 -0
  99. package/dist/index.js +66 -0
  100. package/dist/index.js.map +1 -0
  101. package/dist/kingscorner.d.ts +59 -0
  102. package/dist/kingscorner.js +374 -0
  103. package/dist/kingscorner.js.map +1 -0
  104. package/dist/kingscup.d.ts +44 -0
  105. package/dist/kingscup.js +185 -0
  106. package/dist/kingscup.js.map +1 -0
  107. package/dist/kit.d.ts +283 -0
  108. package/dist/kit.js +73 -0
  109. package/dist/kit.js.map +1 -0
  110. package/dist/klondike.d.ts +57 -0
  111. package/dist/klondike.js +314 -0
  112. package/dist/klondike.js.map +1 -0
  113. package/dist/liarspoker.d.ts +61 -0
  114. package/dist/liarspoker.js +298 -0
  115. package/dist/liarspoker.js.map +1 -0
  116. package/dist/memory.d.ts +36 -0
  117. package/dist/memory.js +181 -0
  118. package/dist/memory.js.map +1 -0
  119. package/dist/minigolf.d.ts +104 -0
  120. package/dist/minigolf.js +724 -0
  121. package/dist/minigolf.js.map +1 -0
  122. package/dist/minigolfcourse.d.ts +127 -0
  123. package/dist/minigolfcourse.js +470 -0
  124. package/dist/minigolfcourse.js.map +1 -0
  125. package/dist/offers.d.ts +136 -0
  126. package/dist/offers.js +279 -0
  127. package/dist/offers.js.map +1 -0
  128. package/dist/ohhell.d.ts +66 -0
  129. package/dist/ohhell.js +331 -0
  130. package/dist/ohhell.js.map +1 -0
  131. package/dist/oldmaid.d.ts +40 -0
  132. package/dist/oldmaid.js +186 -0
  133. package/dist/oldmaid.js.map +1 -0
  134. package/dist/pinochle.d.ts +94 -0
  135. package/dist/pinochle.js +466 -0
  136. package/dist/pinochle.js.map +1 -0
  137. package/dist/pitch.d.ts +77 -0
  138. package/dist/pitch.js +362 -0
  139. package/dist/pitch.js.map +1 -0
  140. package/dist/poker.d.ts +79 -0
  141. package/dist/poker.js +268 -0
  142. package/dist/poker.js.map +1 -0
  143. package/dist/president.d.ts +56 -0
  144. package/dist/president.js +339 -0
  145. package/dist/president.js.map +1 -0
  146. package/dist/pyramid.d.ts +39 -0
  147. package/dist/pyramid.js +200 -0
  148. package/dist/pyramid.js.map +1 -0
  149. package/dist/registry.d.ts +17 -0
  150. package/dist/registry.js +70 -0
  151. package/dist/registry.js.map +1 -0
  152. package/dist/reversi.d.ts +69 -0
  153. package/dist/reversi.js +584 -0
  154. package/dist/reversi.js.map +1 -0
  155. package/dist/ridethebus.d.ts +48 -0
  156. package/dist/ridethebus.js +257 -0
  157. package/dist/ridethebus.js.map +1 -0
  158. package/dist/rummy500.d.ts +89 -0
  159. package/dist/rummy500.js +474 -0
  160. package/dist/rummy500.js.map +1 -0
  161. package/dist/sevens.d.ts +48 -0
  162. package/dist/sevens.js +221 -0
  163. package/dist/sevens.js.map +1 -0
  164. package/dist/slapjack.d.ts +42 -0
  165. package/dist/slapjack.js +224 -0
  166. package/dist/slapjack.js.map +1 -0
  167. package/dist/speed.d.ts +44 -0
  168. package/dist/speed.js +201 -0
  169. package/dist/speed.js.map +1 -0
  170. package/dist/spider.d.ts +51 -0
  171. package/dist/spider.js +320 -0
  172. package/dist/spider.js.map +1 -0
  173. package/dist/spite.d.ts +75 -0
  174. package/dist/spite.js +372 -0
  175. package/dist/spite.js.map +1 -0
  176. package/dist/sport.d.ts +266 -0
  177. package/dist/sport.js +48 -0
  178. package/dist/sport.js.map +1 -0
  179. package/dist/switch.d.ts +69 -0
  180. package/dist/switch.js +393 -0
  181. package/dist/switch.js.map +1 -0
  182. package/dist/table.d.ts +284 -0
  183. package/dist/table.js +67 -0
  184. package/dist/table.js.map +1 -0
  185. package/dist/talk.d.ts +17 -0
  186. package/dist/talk.js +44 -0
  187. package/dist/talk.js.map +1 -0
  188. package/dist/thirtyone.d.ts +66 -0
  189. package/dist/thirtyone.js +372 -0
  190. package/dist/thirtyone.js.map +1 -0
  191. package/dist/tictactoe.d.ts +68 -0
  192. package/dist/tictactoe.js +541 -0
  193. package/dist/tictactoe.js.map +1 -0
  194. package/dist/tricks.d.ts +21 -0
  195. package/dist/tricks.js +48 -0
  196. package/dist/tricks.js.map +1 -0
  197. package/dist/war.d.ts +44 -0
  198. package/dist/war.js +185 -0
  199. package/dist/war.js.map +1 -0
  200. package/dist/whist.d.ts +52 -0
  201. package/dist/whist.js +218 -0
  202. package/dist/whist.js.map +1 -0
  203. package/dist/yacht.d.ts +96 -0
  204. package/dist/yacht.js +653 -0
  205. package/dist/yacht.js.map +1 -0
  206. package/package.json +41 -4
  207. package/README.md +0 -3
@@ -0,0 +1,646 @@
1
+ import { clone, FULL, GameError, makeDeck, markReady, nextHandChoices, nextRandom, rankLabel, rankSeats, shuffled, SUITS, teamOfSeat, thinkFor, who, careless, } from "./kit.js";
2
+ export const classicCanasta = { id: "canasta-classic", name: "Classic Canasta", playTo: 5000, drawTwo: false, minimums: true };
3
+ export const modernCanasta = { ...classicCanasta, id: "canasta-modern", name: "Draw two", drawTwo: true };
4
+ export const easyCanasta = { ...classicCanasta, id: "canasta-easy", name: "Easygoing (no minimums, to 3,000)", playTo: 3000, minimums: false };
5
+ // ---- Cards --------------------------------------------------------------------------------------
6
+ const pip = (c) => c;
7
+ export const isWild = (c) => c.kind === "joker" || pip(c).rank === 2;
8
+ const isThree = (c) => c.kind === "pip" && pip(c).rank === 3;
9
+ export const isRedThree = (c) => isThree(c) && (pip(c).suit === "hearts" || pip(c).suit === "diamonds");
10
+ const isBlackThree = (c) => isThree(c) && !isRedThree(c);
11
+ const rankOf = (c) => (c.kind === "joker" ? 0 : pip(c).rank);
12
+ /** What a card counts in a meld (and against you, left in your hand). */
13
+ export function canastaValue(c) {
14
+ if (c.kind === "joker")
15
+ return 50;
16
+ const r = pip(c).rank;
17
+ if (r === 2 || r === 14)
18
+ return 20;
19
+ if (r >= 8)
20
+ return 10;
21
+ return 5;
22
+ }
23
+ const total = (cards) => cards.reduce((n, c) => n + canastaValue(c), 0);
24
+ const rankName = (r) => (r === 14 ? "Aces" : r === 13 ? "Kings" : r === 12 ? "Queens" : r === 11 ? "Jacks" : `${rankLabel(r)}s`);
25
+ const wildsIn = (m) => m.cards.filter(isWild).length;
26
+ export const isCanasta = (m) => m.cards.length >= 7;
27
+ const natural = (m) => wildsIn(m) === 0;
28
+ const sortHand = (cards) => [...cards].sort((a, b) => key(a) - key(b));
29
+ const key = (c) => (c.kind === "joker" ? 1000 : pip(c).rank === 2 ? 900 : pip(c).rank * 10 + SUITS.indexOf(pip(c).suit));
30
+ /** The opening minimum for a side with this score. */
31
+ export function openingNeed(rules, score) {
32
+ if (!rules.minimums)
33
+ return 0;
34
+ return score < 0 ? 15 : score < 1500 ? 50 : score < 3000 ? 90 : 120;
35
+ }
36
+ // ---- Dealing ------------------------------------------------------------------------------------
37
+ /** Lay out any red threes in this hand and draw replacements (which may be red threes too). */
38
+ function layRedThrees(s, seat) {
39
+ const team = teamOfSeat(seat);
40
+ for (let guard = 0; guard < 10; guard++) {
41
+ const reds = s.hands[seat].filter(isRedThree);
42
+ if (!reds.length)
43
+ return;
44
+ s.hands[seat] = s.hands[seat].filter((c) => !isRedThree(c));
45
+ s.red3s[team].push(...reds);
46
+ for (let i = 0; i < reds.length && s.stock.length; i++)
47
+ s.hands[seat].push(s.stock.pop());
48
+ }
49
+ }
50
+ function deal(s) {
51
+ const rand = nextRandom(s);
52
+ const deck = shuffled(makeDeck({ ranks: FULL, copies: 2, jokers: 2 }), rand);
53
+ s.hands = [0, 1, 2, 3].map((i) => deck.slice(i * 11, i * 11 + 11));
54
+ s.stock = deck.slice(44);
55
+ s.melds = [[], []];
56
+ s.red3s = [[], []];
57
+ s.opened = [false, false];
58
+ s.laid = [];
59
+ s.tookBack = false;
60
+ for (let seat = 0; seat < 4; seat++)
61
+ layRedThrees(s, seat);
62
+ for (let seat = 0; seat < 4; seat++)
63
+ s.hands[seat] = sortHand(s.hands[seat]);
64
+ // Turn up the first discard; keep going past wild cards and threes (a red three goes back in the stock).
65
+ s.pile = [];
66
+ for (let guard = 0; guard < 20; guard++) {
67
+ const top = s.stock.pop();
68
+ if (isRedThree(top)) {
69
+ s.stock.unshift(top);
70
+ continue;
71
+ }
72
+ s.pile.push(top);
73
+ if (!isWild(top) && !isThree(top))
74
+ break;
75
+ }
76
+ s.ready = [false, false, false, false];
77
+ s.phase = "draw";
78
+ s.turn = (s.dealer + 1) % 4;
79
+ s.narration = null;
80
+ }
81
+ export function newCanasta(rules, _seats, seed) {
82
+ const s = { game: "canasta", rules, seats: 4, rng: seed >>> 0, hand: 0, dealer: seed % 4, scores: [0, 0], lastHand: null };
83
+ deal(s);
84
+ return s;
85
+ }
86
+ /** The side would have a canasta once these groups are down. */
87
+ function canastaAfter(melds, groups) {
88
+ if (melds.some(isCanasta))
89
+ return true;
90
+ return groups.some((g) => (g.onto >= 0 ? melds[g.onto].cards.length : 0) + g.naturals.length + g.wilds.length >= 7);
91
+ }
92
+ /** Check a set of groups against the side's melds; returns a problem, or null. */
93
+ function groupProblem(melds, groups) {
94
+ for (const g of groups) {
95
+ const existing = g.onto >= 0 ? melds[g.onto] : null;
96
+ const naturals = g.naturals.length + (existing ? existing.cards.length - wildsIn(existing) : 0);
97
+ const wilds = g.wilds.length + (existing ? wildsIn(existing) : 0);
98
+ if (naturals < 2)
99
+ return `A meld needs at least two natural ${rankName(g.rank).toLowerCase()}.`;
100
+ if (naturals + wilds < 3)
101
+ return "A meld is three cards or more.";
102
+ if (wilds > 3)
103
+ return "A meld can have three wild cards at most.";
104
+ }
105
+ return null;
106
+ }
107
+ /** How the chosen cards would go down, or why they can't. */
108
+ export function meldPlan(s, seat, ids) {
109
+ const team = teamOfSeat(seat);
110
+ const hand = s.hands[seat];
111
+ const cards = ids.map((id) => hand.find((c) => c.id === id));
112
+ if (!cards.length || cards.some((c) => !c))
113
+ return { error: "Pick cards from your hand to meld." };
114
+ if (new Set(ids).size !== ids.length)
115
+ return { error: "Pick each card once." };
116
+ const chosen = cards;
117
+ const wilds = chosen.filter(isWild);
118
+ const byRank = new Map();
119
+ for (const c of chosen.filter((c) => !isWild(c)))
120
+ byRank.set(rankOf(c), [...(byRank.get(rankOf(c)) ?? []), c]);
121
+ if (byRank.size > 1 && wilds.length)
122
+ return { error: "Meld wild cards with one rank at a time." };
123
+ const melds = s.melds[team];
124
+ const groups = [];
125
+ if (!byRank.size) {
126
+ // Only wild cards: they go on the side's meld with the most cards that has room.
127
+ const room = melds
128
+ .map((m, i) => ({ m, i }))
129
+ .filter(({ m }) => m.rank !== 3 && wildsIn(m) + wilds.length <= 3)
130
+ .sort((a, b) => Number(isCanasta(a.m)) - Number(isCanasta(b.m)) || b.m.cards.length - a.m.cards.length)[0];
131
+ if (!room)
132
+ return { error: melds.length ? "No meld has room for that many wild cards (three at most)." : "Wild cards need natural cards to meld with." };
133
+ groups.push({ rank: room.m.rank, naturals: [], wilds, onto: room.i });
134
+ }
135
+ else {
136
+ for (const [rank, naturals] of byRank)
137
+ groups.push({ rank, naturals, wilds: byRank.size === 1 ? wilds : [], onto: melds.findIndex((m) => m.rank === rank) });
138
+ }
139
+ if (groups.some((g) => g.rank === 3)) {
140
+ if (chosen.some(isRedThree))
141
+ return { error: "Red threes go down by themselves." };
142
+ if (groups.some((g) => g.rank === 3 && g.wilds.length))
143
+ return { error: "Black threes can't meld with wild cards." };
144
+ if (hand.length - chosen.length > 1)
145
+ return { error: "Black threes only meld when you're going out." };
146
+ }
147
+ const problem = groupProblem(melds, groups);
148
+ if (problem)
149
+ return { error: problem };
150
+ const left = hand.length - chosen.length;
151
+ if (left <= 1 && !canastaAfter(melds, groups))
152
+ return { error: left === 0 ? "Your side needs a canasta before you can go out." : "That leaves one card to discard and go out, and your side has no canasta yet." };
153
+ return { groups };
154
+ }
155
+ function layGroups(s, seat, groups) {
156
+ const team = teamOfSeat(seat);
157
+ const used = new Set(groups.flatMap((g) => [...g.naturals, ...g.wilds].map((c) => c.id)));
158
+ s.hands[seat] = s.hands[seat].filter((c) => !used.has(c.id));
159
+ for (const g of groups) {
160
+ const cards = [...g.naturals, ...g.wilds];
161
+ if (g.onto >= 0)
162
+ s.melds[team][g.onto].cards.push(...cards);
163
+ else
164
+ s.melds[team].push({ rank: g.rank, cards });
165
+ }
166
+ for (const m of s.melds[team])
167
+ m.cards = [...m.cards.filter((c) => !isWild(c)), ...m.cards.filter(isWild)];
168
+ }
169
+ /** Whether the top of the pile can be taken, and how. */
170
+ export function takePlan(s, seat) {
171
+ const team = teamOfSeat(seat);
172
+ const top = s.pile.at(-1);
173
+ if (!top)
174
+ return { error: "The pile is empty." };
175
+ if (isWild(top))
176
+ return { error: "You can't take a wild card off the pile." };
177
+ if (isBlackThree(top))
178
+ return { error: "A black three blocks the pile: draw instead." };
179
+ const rank = rankOf(top);
180
+ const hand = s.hands[seat];
181
+ const naturals = hand.filter((c) => !isWild(c) && rankOf(c) === rank);
182
+ const wilds = hand.filter(isWild);
183
+ const frozen = s.pile.some(isWild) || !s.opened[team];
184
+ const melds = s.melds[team];
185
+ const onto = melds.findIndex((m) => m.rank === rank);
186
+ const name = rankName(rank).toLowerCase();
187
+ let groups;
188
+ // The top card is part of the first group (counted as a natural it brings).
189
+ if (!frozen && onto >= 0)
190
+ groups = [{ rank, naturals: [top], wilds: [], onto }];
191
+ else if (naturals.length >= 2)
192
+ groups = [{ rank, naturals: [top, ...naturals], wilds: [], onto }];
193
+ else if (!frozen && naturals.length === 1 && wilds.length)
194
+ groups = [{ rank, naturals: [top, naturals[0]], wilds: [wilds[0]], onto }];
195
+ else
196
+ return { error: frozen ? `The pile is frozen: you need two ${name} in your hand to take it.` : `You need two ${name}, or one and a wild card, to take the pile.` };
197
+ if (!s.opened[team]) {
198
+ const need = openingNeed(s.rules, s.scores[team]);
199
+ const worth = () => groups.reduce((n, g) => n + total(g.naturals) + total(g.wilds), 0);
200
+ // Not enough to open? Add wild cards to it, then any other melds in the hand.
201
+ const spare = wilds.filter((w) => !groups[0].wilds.includes(w));
202
+ while (worth() < need && spare.length && groups[0].wilds.length < 3)
203
+ groups[0].wilds.push(spare.shift());
204
+ const others = new Map();
205
+ for (const c of hand)
206
+ if (!isWild(c) && !isThree(c) && rankOf(c) !== rank)
207
+ others.set(rankOf(c), [...(others.get(rankOf(c)) ?? []), c]);
208
+ for (const [r, cards] of [...others].sort((a, b) => total(b[1]) - total(a[1]))) {
209
+ if (worth() >= need)
210
+ break;
211
+ if (cards.length >= 3)
212
+ groups.push({ rank: r, naturals: cards, wilds: [], onto: -1 });
213
+ }
214
+ if (worth() < need)
215
+ return { error: `To open your side needs ${need}: taking the pile only makes ${worth()}.` };
216
+ }
217
+ const problem = groupProblem(melds, groups);
218
+ if (problem)
219
+ return { error: problem };
220
+ // You need cards left to play on (one to discard, and one more unless you can go out).
221
+ const fromHand = groups.reduce((n, g) => n + g.naturals.length + g.wilds.length, 0) - 1;
222
+ const left = hand.length - fromHand + s.pile.length - 1;
223
+ if (left < 2 && !canastaAfter(melds, groups))
224
+ return { error: "Taking the pile would leave you too few cards to play on." };
225
+ return { groups };
226
+ }
227
+ function laidWorth(s, team) {
228
+ return total(s.melds[team].flatMap((m) => m.cards).filter((c) => s.laid.includes(c.id)));
229
+ }
230
+ function apply(state, seat, move) {
231
+ const s = clone(state);
232
+ const team = teamOfSeat(seat);
233
+ if (move.type === "next") {
234
+ if (s.phase !== "scored")
235
+ throw new GameError("The hand isn't over.");
236
+ if (markReady(s.ready, seat)) {
237
+ s.hand += 1;
238
+ s.dealer = (s.dealer + 1) % 4;
239
+ deal(s);
240
+ }
241
+ return s;
242
+ }
243
+ if (s.turn !== seat || (s.phase !== "draw" && s.phase !== "meld"))
244
+ throw new GameError("It's not your turn.");
245
+ switch (move.type) {
246
+ case "draw": {
247
+ if (s.phase !== "draw")
248
+ throw new GameError("You've already drawn.");
249
+ if (!s.stock.length) {
250
+ s.narration = "The stock ran out.";
251
+ scoreHand(s, null);
252
+ return s;
253
+ }
254
+ const n = s.rules.drawTwo ? 2 : 1;
255
+ for (let i = 0; i < n && s.stock.length; i++)
256
+ s.hands[seat].push(s.stock.pop());
257
+ const reds = s.red3s[team].length;
258
+ layRedThrees(s, seat);
259
+ s.hands[seat] = sortHand(s.hands[seat]);
260
+ // Red threes with nothing left to replace them can leave too few cards to play on: with the
261
+ // stock gone, the hand is over (or, holding nothing and with a canasta, you've gone out).
262
+ const left = s.hands[seat].length;
263
+ const canasta = s.melds[team].some(isCanasta);
264
+ if (left === 0 && canasta) {
265
+ goOut(s, seat);
266
+ return s;
267
+ }
268
+ if (!s.stock.length && (left === 0 || (left === 1 && !canasta))) {
269
+ s.narration = "The stock ran out.";
270
+ scoreHand(s, null);
271
+ return s;
272
+ }
273
+ s.phase = "meld";
274
+ s.narration = s.red3s[team].length > reds ? `${who(seat)} drew a red three` : null;
275
+ return s;
276
+ }
277
+ case "take": {
278
+ if (s.phase !== "draw")
279
+ throw new GameError("You've already drawn.");
280
+ const plan = takePlan(s, seat);
281
+ if ("error" in plan)
282
+ throw new GameError(plan.error);
283
+ const top = s.pile.pop();
284
+ const pile = s.pile;
285
+ s.pile = [];
286
+ // The top card isn't in the hand: lay it with the rest.
287
+ s.hands[seat].push(top);
288
+ layGroups(s, seat, plan.groups);
289
+ s.red3s[team].push(...pile.filter(isRedThree));
290
+ s.hands[seat] = sortHand([...s.hands[seat], ...pile.filter((c) => !isRedThree(c))]);
291
+ s.opened[team] = true;
292
+ s.laid = [];
293
+ s.phase = "meld";
294
+ s.narration = `${who(seat)} took the pile (${pile.length + 1} cards)`;
295
+ if (!s.hands[seat].length)
296
+ goOut(s, seat);
297
+ return s;
298
+ }
299
+ case "meld": {
300
+ if (s.phase !== "meld")
301
+ throw new GameError("Draw first: tap the stock, or the pile to take it.");
302
+ const plan = meldPlan(s, seat, move.cards ?? []);
303
+ if ("error" in plan)
304
+ throw new GameError(plan.error);
305
+ if (!s.opened[team])
306
+ s.laid.push(...(move.cards ?? []));
307
+ layGroups(s, seat, plan.groups);
308
+ s.narration = null;
309
+ if (!s.hands[seat].length) {
310
+ const need = openingNeed(s.rules, s.scores[team]);
311
+ if (!s.opened[team] && laidWorth(s, team) < need)
312
+ throw new GameError(`Your side's first melds need ${need} to go out on.`);
313
+ goOut(s, seat);
314
+ }
315
+ return s;
316
+ }
317
+ case "takeback": {
318
+ if (s.phase !== "meld" || s.opened[team] || !s.laid.length)
319
+ throw new GameError("There's nothing to take back.");
320
+ const back = s.melds[team].flatMap((m) => m.cards).filter((c) => s.laid.includes(c.id));
321
+ for (const m of s.melds[team])
322
+ m.cards = m.cards.filter((c) => !s.laid.includes(c.id));
323
+ s.melds[team] = s.melds[team].filter((m) => m.cards.length);
324
+ s.hands[seat] = sortHand([...s.hands[seat], ...back]);
325
+ s.laid = [];
326
+ s.tookBack = true;
327
+ return s;
328
+ }
329
+ case "discard": {
330
+ if (s.phase !== "meld")
331
+ throw new GameError("Draw first: tap the stock, or the pile to take it.");
332
+ const ids = move.cards ?? (move.card ? [move.card] : []);
333
+ if (ids.length !== 1)
334
+ throw new GameError("Pick one card to discard.");
335
+ const card = s.hands[seat].find((c) => c.id === ids[0]);
336
+ if (!card)
337
+ throw new GameError("You don't have that card.");
338
+ if (!s.opened[team] && s.laid.length) {
339
+ const need = openingNeed(s.rules, s.scores[team]);
340
+ const worth = laidWorth(s, team);
341
+ if (worth < need)
342
+ throw new GameError(`Your side's first melds need ${need}, and these make ${worth}. Meld more, or take them back.`);
343
+ s.opened[team] = true;
344
+ }
345
+ if (s.hands[seat].length === 1 && !s.melds[team].some(isCanasta))
346
+ throw new GameError("Your side needs a canasta before you can go out.");
347
+ s.hands[seat] = s.hands[seat].filter((c) => c.id !== card.id);
348
+ s.pile.push(card);
349
+ s.laid = [];
350
+ s.tookBack = false;
351
+ s.narration = isWild(card) ? `${who(seat)} froze the pile` : null;
352
+ if (!s.hands[seat].length) {
353
+ goOut(s, seat);
354
+ return s;
355
+ }
356
+ s.turn = (seat + 1) % 4;
357
+ s.phase = "draw";
358
+ return s;
359
+ }
360
+ default:
361
+ throw new GameError("That's not a move in Canasta.");
362
+ }
363
+ }
364
+ function goOut(s, seat) {
365
+ const team = teamOfSeat(seat);
366
+ if (!s.opened[team] && s.laid.length)
367
+ s.opened[team] = true;
368
+ s.narration = `${who(seat)} went out!`;
369
+ scoreHand(s, seat);
370
+ }
371
+ function scoreHand(s, wentOut) {
372
+ const rows = { Canastas: [0, 0], "Red threes": [0, 0], "Going out": [0, 0], "Melded cards": [0, 0], "Left in hand": [0, 0] };
373
+ for (const t of [0, 1]) {
374
+ const melds = s.melds[t];
375
+ rows.Canastas[t] = melds.filter(isCanasta).reduce((n, m) => n + (natural(m) ? 500 : 300), 0);
376
+ const reds = s.red3s[t].length === 4 ? 800 : s.red3s[t].length * 100;
377
+ rows["Red threes"][t] = melds.length ? reds : -reds;
378
+ rows["Going out"][t] = wentOut !== null && teamOfSeat(wentOut) === t ? 100 : 0;
379
+ rows["Melded cards"][t] = total(melds.flatMap((m) => m.cards));
380
+ rows["Left in hand"][t] = -total([...s.hands[t], ...s.hands[t + 2]]);
381
+ }
382
+ const hand = [0, 1].map((t) => Object.values(rows).reduce((n, r) => n + r[t], 0));
383
+ s.scores = [s.scores[0] + hand[0], s.scores[1] + hand[1]];
384
+ s.lastHand = { wentOut, rows: { ...rows, "This hand": hand } };
385
+ s.ready = [false, false, false, false];
386
+ s.phase = s.scores.some((n) => n >= s.rules.playTo) ? "finished" : "scored";
387
+ }
388
+ const winner = (s) => (s.scores[0] >= s.scores[1] ? 0 : 1);
389
+ // ---- What each player sees ---------------------------------------------------------------------
390
+ function meldsView(s) {
391
+ const out = [];
392
+ for (const t of [0, 1]) {
393
+ for (const m of [...s.melds[t]].sort((a, b) => b.cards.length - a.cards.length || b.rank - a.rank)) {
394
+ const done = isCanasta(m);
395
+ const wild = wildsIn(m);
396
+ out.push({
397
+ owner: t,
398
+ byTeam: true,
399
+ label: done ? `${rankName(m.rank)} ✓ ${natural(m) ? "natural" : "mixed"} canasta` : `${rankName(m.rank)} ×${m.cards.length}${wild ? ` (${wild} wild)` : ""}`,
400
+ // Squared up, as at a real table: a canasta shows its top card; a meld, three at most.
401
+ cards: done ? [m.cards[0]] : m.cards.length <= 3 ? m.cards : [m.cards[0], m.cards[1], m.cards.at(-1)],
402
+ });
403
+ }
404
+ if (s.red3s[t].length)
405
+ out.push({ owner: t, byTeam: true, label: `Red threes ×${s.red3s[t].length}`, cards: [s.red3s[t][0]] });
406
+ }
407
+ return out;
408
+ }
409
+ function view(s, seat) {
410
+ const team = teamOfSeat(seat);
411
+ const mine = s.turn === seat;
412
+ const over = s.phase === "finished";
413
+ const playing = s.phase === "draw" || s.phase === "meld";
414
+ const need = openingNeed(s.rules, s.scores[team]);
415
+ const takeback = s.phase === "meld" && mine && !s.opened[team] && s.laid.length > 0;
416
+ const top = s.pile.at(-1);
417
+ const frozen = s.pile.some(isWild);
418
+ const w = over ? winner(s) : null;
419
+ return {
420
+ game: "canasta",
421
+ seats: 4,
422
+ me: seat,
423
+ teams: true,
424
+ phase: s.phase,
425
+ turn: playing ? s.turn : null,
426
+ dealer: s.dealer,
427
+ hand: s.hands[seat],
428
+ tap: null,
429
+ pick: s.phase === "meld" && mine
430
+ ? {
431
+ min: 1,
432
+ max: s.hands[seat].length,
433
+ prompt: "Pick cards to meld, or one to discard.",
434
+ cards: s.hands[seat].map((c) => c.id),
435
+ actions: [
436
+ { label: "Meld", type: "meld" },
437
+ { label: "Discard", type: "discard", primary: true },
438
+ ],
439
+ }
440
+ : null,
441
+ choices: takeback
442
+ ? { prompt: `Your side's first melds need ${need}: these make ${laidWorth(s, team)}.`, options: [{ label: "Take them back", move: { type: "takeback" } }] }
443
+ : s.phase === "scored"
444
+ ? nextHandChoices(s.ready, seat, false)
445
+ : null,
446
+ players: s.hands.map((h, i) => ({ cards: h.length, line: "", team: teamOfSeat(i), dealer: i === s.dealer })),
447
+ center: {
448
+ kind: "piles",
449
+ stock: s.stock.length,
450
+ discard: s.pile.slice(-3),
451
+ discardCount: s.pile.length,
452
+ drawMove: s.phase === "draw" && mine ? { type: "draw" } : null,
453
+ takeMove: s.phase === "draw" && mine && top ? { type: "take" } : null,
454
+ note: frozen ? "Frozen" : top && isBlackThree(top) ? "Blocked" : !s.opened[team] && s.pile.length ? "Frozen until your side melds" : null,
455
+ },
456
+ melds: meldsView(s),
457
+ info: [
458
+ ...(playing && !s.opened[team] && need ? [`Your side needs ${need} to open`] : []),
459
+ ...(playing && s.phase === "draw" && mine ? ["Tap the stock to draw, or the pile to take it"] : []),
460
+ `To ${s.rules.playTo.toLocaleString("en-US")}`,
461
+ ],
462
+ narration: s.narration,
463
+ scores: [
464
+ { seats: [0, 2], value: s.scores[0] },
465
+ { seats: [1, 3], value: s.scores[1] },
466
+ ],
467
+ result: s.lastHand && (s.phase === "scored" || over)
468
+ ? {
469
+ key: `canasta-${s.hand}`,
470
+ title: s.lastHand.wentOut !== null ? `${who(s.lastHand.wentOut)} went out` : "The stock ran out",
471
+ sides: [
472
+ [0, 2],
473
+ [1, 3],
474
+ ],
475
+ rows: [
476
+ ...Object.entries(s.lastHand.rows).map(([label, values]) => ({
477
+ label,
478
+ values: values.map((v) => (v > 0 ? `+${v}` : v < 0 ? `−${-v}` : "0")),
479
+ tone: label === "This hand" ? "score" : undefined,
480
+ })),
481
+ { label: "Score", values: s.scores.map(String), tone: "total" },
482
+ ],
483
+ }
484
+ : null,
485
+ over: w !== null ? { winners: [w, w + 2], title: `${who(w)} & ${who(w + 2)} win ${s.scores[w].toLocaleString("en-US")} to ${s.scores[1 - w].toLocaleString("en-US")}` } : null,
486
+ };
487
+ }
488
+ // ---- Computer players --------------------------------------------------------------------------
489
+ /** Natural groups in a hand, by rank (not threes). */
490
+ function naturalGroups(hand) {
491
+ const out = new Map();
492
+ for (const c of hand)
493
+ if (!isWild(c) && !isThree(c))
494
+ out.set(rankOf(c), [...(out.get(rankOf(c)) ?? []), c]);
495
+ return out;
496
+ }
497
+ function bot(s, seat, style) {
498
+ if (s.phase === "scored")
499
+ return s.ready[seat] ? null : { type: "next" };
500
+ if (s.turn !== seat)
501
+ return null;
502
+ const team = teamOfSeat(seat);
503
+ const hand = s.hands[seat];
504
+ const sloppy = careless(style);
505
+ if (s.phase === "draw") {
506
+ const take = takePlan(s, seat);
507
+ return !("error" in take) && (s.pile.length >= 2 || !sloppy) ? { type: "take" } : { type: "draw" };
508
+ }
509
+ const melds = s.melds[team];
510
+ const canasta = melds.some(isCanasta);
511
+ const ok = (ids) => !("error" in meldPlan(s, seat, ids));
512
+ // Keep two cards back unless the side can go out.
513
+ const spare = (n) => canasta || hand.length - n >= 2;
514
+ if (s.opened[team]) {
515
+ // Naturals onto our melds, new natural melds, then wild cards to finish a canasta.
516
+ for (const [rank, cards] of naturalGroups(hand)) {
517
+ const ids = cards.map((c) => c.id);
518
+ if ((melds.some((m) => m.rank === rank) || cards.length >= 3) && spare(ids.length) && ok(ids))
519
+ return { type: "meld", cards: ids };
520
+ }
521
+ const wilds = hand.filter(isWild);
522
+ for (const m of melds) {
523
+ const needed = 7 - m.cards.length;
524
+ if (!isCanasta(m) && needed > 0 && needed <= wilds.length && wildsIn(m) + needed <= 3 && spare(needed)) {
525
+ const ids = wilds.slice(0, needed).map((c) => c.id);
526
+ if (ok(ids))
527
+ return { type: "meld", cards: ids };
528
+ }
529
+ }
530
+ // Going out: everything left that melds.
531
+ if (canasta && hand.length <= 3 && ok(hand.map((c) => c.id)))
532
+ return { type: "meld", cards: hand.map((c) => c.id) };
533
+ }
534
+ else if (!s.laid.length && !s.tookBack && !sloppy) {
535
+ // Open only with enough: natural groups of three or more, then pairs with a wild card.
536
+ const need = openingNeed(s.rules, s.scores[team]);
537
+ const plan = [];
538
+ let worth = 0;
539
+ const wilds = hand.filter(isWild);
540
+ for (const cards of [...naturalGroups(hand).values()].sort((a, b) => total(b) - total(a))) {
541
+ if (cards.length >= 3)
542
+ plan.push(cards.map((c) => c.id)), (worth += total(cards));
543
+ else if (cards.length === 2 && wilds.length && worth < need) {
544
+ const w = wilds.shift();
545
+ plan.push([...cards, w].map((c) => c.id));
546
+ worth += total(cards) + canastaValue(w);
547
+ }
548
+ }
549
+ const used = plan.flat().length;
550
+ if (plan.length && worth >= need && spare(used) && plan.every(ok))
551
+ return { type: "meld", cards: plan[0] };
552
+ }
553
+ else if (s.laid.length) {
554
+ // Mid-way through opening: lay the rest of the plan (groups of three, or pairs with a wild card).
555
+ for (const [, cards] of naturalGroups(hand)) {
556
+ const ids = cards.map((c) => c.id);
557
+ if (cards.length >= 3 && spare(ids.length) && ok(ids))
558
+ return { type: "meld", cards: ids };
559
+ }
560
+ const wild = hand.find(isWild);
561
+ for (const [, cards] of naturalGroups(hand)) {
562
+ const ids = [...cards.map((c) => c.id), ...(wild ? [wild.id] : [])];
563
+ if (cards.length === 2 && wild && spare(3) && ok(ids))
564
+ return { type: "meld", cards: ids };
565
+ }
566
+ const need = openingNeed(s.rules, s.scores[team]);
567
+ if (laidWorth(s, team) < need)
568
+ return { type: "takeback" };
569
+ }
570
+ // Discard: a black three; else a lone card the other side can't use, cheapest first; never a wild if avoidable.
571
+ const theirs = new Set(s.melds[(1 - team)].map((m) => m.rank));
572
+ const ours = new Set(melds.map((m) => m.rank));
573
+ const counts = naturalGroups(hand);
574
+ const score = (c) => {
575
+ if (isBlackThree(c))
576
+ return -100;
577
+ if (isWild(c))
578
+ return 1000;
579
+ const r = rankOf(c);
580
+ return (counts.get(r)?.length ?? 0) * 30 + (ours.has(r) ? 60 : 0) + (theirs.has(r) ? 80 : 0) + canastaValue(c);
581
+ };
582
+ const pick = sloppy ? hand[Math.floor(Math.random() * hand.length)] : [...hand].sort((a, b) => score(a) - score(b))[0];
583
+ // Holding one card without a canasta can't happen (melds keep two back), so this discard is legal.
584
+ return { type: "discard", cards: [pick.id] };
585
+ }
586
+ export const canasta = {
587
+ id: "canasta",
588
+ name: "Canasta",
589
+ emoji: "🧺",
590
+ blurb: "Partners, melds and canastas",
591
+ tagline: "Meld sevens of a kind into canastas, grab the pile when you can, and go out together. Partners across.",
592
+ seats: { min: 4, max: 4, default: 4 },
593
+ teams: true,
594
+ presets: [classicCanasta, modernCanasta, easyCanasta],
595
+ fields: [
596
+ { key: "playTo", label: "Game to", kind: "choice", options: [[3000, "3,000"], [5000, "5,000"], [7500, "7,500"]] },
597
+ { key: "drawTwo", label: "Draw two", hint: "Take two cards from the stock each turn.", kind: "toggle" },
598
+ { key: "minimums", label: "Opening minimums", hint: "A side's first melds must be worth 50 (90 from 1,500, 120 from 3,000).", kind: "toggle" },
599
+ ],
600
+ normalize(rules) {
601
+ const given = (rules && typeof rules === "object" ? rules : {});
602
+ const base = [classicCanasta, modernCanasta, easyCanasta].find((p) => p.id === given.id) ?? classicCanasta;
603
+ return { ...base, ...given };
604
+ },
605
+ validate(r) {
606
+ return r.playTo >= 1000 && r.playTo <= 10000 ? [] : ["Play to between 1,000 and 10,000."];
607
+ },
608
+ describe(r) {
609
+ return [
610
+ "Partners across. Two decks with four jokers; eleven cards each. Twos and jokers are wild.",
611
+ `Each turn, draw ${r.drawTwo ? "two cards" : "a card"} from the stock, or take the whole discard pile if you can meld its top card: with two of that rank from your hand, or by adding it to your side's meld. Then meld if you like, and discard one.`,
612
+ "A meld is three or more of a rank, at least two of them natural, with three wild cards at most. Seven cards make a canasta: 500 if natural, 300 with wild cards.",
613
+ `${r.minimums ? "Your side's first melds must be worth 50 (90 from 1,500 points, 120 from 3,000). " : ""}Until your side has melded, or while a wild card is in it, the pile is frozen: you need a natural pair to take it. A black three on top blocks it.`,
614
+ "Red threes go down at once for 100 each (800 for all four), but count against a side that hasn't melded. Jokers count 50, aces and twos 20, eights to kings 10, the rest 5.",
615
+ `Your side needs a canasta to go out (100 more). Cards left in hand count against you. First to ${r.playTo.toLocaleString("en-US")} wins.`,
616
+ ];
617
+ },
618
+ newGame: newCanasta,
619
+ apply,
620
+ view,
621
+ bot,
622
+ thinkingTime(s, seat) {
623
+ if (s.phase === "scored")
624
+ return 1400;
625
+ if (s.phase === "draw")
626
+ return 1300;
627
+ return thinkFor(Math.min(s.hands[seat]?.length ?? 1, 8), 0.8);
628
+ },
629
+ summary(s) {
630
+ const over = s.phase === "finished";
631
+ const w = winner(s);
632
+ return {
633
+ game: "canasta",
634
+ rules: s.rules.name,
635
+ phase: s.phase,
636
+ over,
637
+ hand: s.hand + 1,
638
+ scoreLine: `${s.scores[0]} to ${s.scores[1]}`,
639
+ order: over ? rankSeats([{ seats: [0, 2], score: s.scores[0] }, { seats: [1, 3], score: s.scores[1] }]) : null,
640
+ winners: over ? [w, w + 2] : null,
641
+ teams: true,
642
+ seats: 4,
643
+ };
644
+ },
645
+ };
646
+ //# sourceMappingURL=canasta.js.map