@journey-to-code/games 0.0.0-stage → 0.1.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.
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
package/dist/combat.js ADDED
@@ -0,0 +1,772 @@
1
+ import { settleMatch, withOffers } from "./offers.js";
2
+ import { cellAt, cellName, colOf, rowOf } from "./board.js";
3
+ import { careless, clone, GameError, markReady, nextHandChoices, nextRandom, pickRandom, thinkFor, who } from "./table.js";
4
+ export const classicCombat = { id: "combat-classic", name: "Classic", size: 10, fleet: "classic", touch: true, salvo: false, again: false, announce: true, first: "random", games: 1 };
5
+ export const admiralsCombat = { id: "combat-admirals", name: "Admirals (ten ships, apart)", size: 10, fleet: "admirals", touch: false, salvo: false, again: true, announce: true, first: "random", games: 1 };
6
+ export const salvoCombat = { id: "combat-salvo", name: "Salvo (a shot per ship)", size: 10, fleet: "classic", touch: true, salvo: true, again: false, announce: true, first: "random", games: 1 };
7
+ export const quickCombat = { id: "combat-quick", name: "Quick (7×7, three ships)", size: 7, fleet: "quick", touch: true, salvo: false, again: false, announce: true, first: "random", games: 1 };
8
+ const PRESETS = [classicCombat, admiralsCombat, salvoCombat, quickCombat];
9
+ const SIZES = [7, 8, 10, 12];
10
+ /** The ships of each fleet, biggest first: what it's called and how long it is. */
11
+ const SHIPS = {
12
+ carrier: { name: "Carrier", size: 5 },
13
+ cruiser: { name: "Cruiser", size: 4 },
14
+ destroyer: { name: "Destroyer", size: 3 },
15
+ submarine: { name: "Submarine", size: 3 },
16
+ patrol: { name: "Patrol boat", size: 2 },
17
+ scout: { name: "Scout", size: 1 },
18
+ };
19
+ const FLEETS = {
20
+ classic: ["carrier", "cruiser", "destroyer", "submarine", "patrol"],
21
+ admirals: ["cruiser", "destroyer", "destroyer", "patrol", "patrol", "patrol", "scout", "scout", "scout", "scout"],
22
+ quick: ["destroyer", "patrol", "patrol"],
23
+ };
24
+ /** The lengths of a fleet's ships, biggest first. */
25
+ export const fleetSizes = (fleet) => FLEETS[fleet].map((k) => SHIPS[k].size);
26
+ const shipName = (kind) => SHIPS[kind]?.name ?? "Ship";
27
+ const other = (seat) => 1 - seat;
28
+ // ---- Ships on a grid ----------------------------------------------------------------------------
29
+ /** The cells a ship of `size` covers with its bow at `at`, or null if it runs off the grid. */
30
+ export function shipCells(n, size, at, across) {
31
+ const r = rowOf(n, at);
32
+ const c = colOf(n, at);
33
+ if (at < 0 || at >= n * n || (across ? c + size > n : r + size > n))
34
+ return null;
35
+ return Array.from({ length: size }, (_, i) => (across ? at + i : at + i * n));
36
+ }
37
+ const cellsOf = (n, ship) => (ship.at === null ? [] : shipCells(n, ship.size, ship.at, ship.across));
38
+ /** A cell and the (up to eight) cells round it. */
39
+ function around(n, cell) {
40
+ const r = rowOf(n, cell);
41
+ const c = colOf(n, cell);
42
+ const out = [];
43
+ for (let dr = -1; dr <= 1; dr++)
44
+ for (let dc = -1; dc <= 1; dc++)
45
+ if (r + dr >= 0 && r + dr < n && c + dc >= 0 && c + dc < n)
46
+ out.push(cellAt(n, r + dr, c + dc));
47
+ return out;
48
+ }
49
+ /** The (up to four) cells that share a side with a cell. */
50
+ function beside(n, cell) {
51
+ const r = rowOf(n, cell);
52
+ const c = colOf(n, cell);
53
+ const out = [];
54
+ if (r > 0)
55
+ out.push(cell - n);
56
+ if (c > 0)
57
+ out.push(cell - 1);
58
+ if (c < n - 1)
59
+ out.push(cell + 1);
60
+ if (r < n - 1)
61
+ out.push(cell + n);
62
+ return out;
63
+ }
64
+ /** The cells a new ship can't cover: the other ships, and (when ships can't touch) the water round them. */
65
+ function blockedBy(n, ships, touch, except) {
66
+ const blocked = new Set();
67
+ for (const ship of ships) {
68
+ if (ship === except)
69
+ continue;
70
+ for (const cell of cellsOf(n, ship))
71
+ for (const c of touch ? [cell] : around(n, cell))
72
+ blocked.add(c);
73
+ }
74
+ return blocked;
75
+ }
76
+ /** Every cell a ship's bow can go on, lying that way, among the ships already down. */
77
+ function bowsFor(n, size, across, blocked) {
78
+ const out = [];
79
+ for (let at = 0; at < n * n; at++) {
80
+ const cells = shipCells(n, size, at, across);
81
+ if (cells && cells.every((c) => !blocked.has(c)))
82
+ out.push(at);
83
+ }
84
+ return out;
85
+ }
86
+ /** Whether a whole fleet is laid out by the rules: every ship on the grid, none overlapping or (if not allowed) touching. */
87
+ export function fleetIsLegal(rules, ships) {
88
+ return ships.every((ship) => {
89
+ if (ship.at === null)
90
+ return false;
91
+ const cells = shipCells(rules.size, ship.size, ship.at, ship.across);
92
+ const blocked = blockedBy(rules.size, ships, rules.touch, ship);
93
+ return !!cells && cells.every((c) => !blocked.has(c));
94
+ });
95
+ }
96
+ const emptyFleet = (fleet) => FLEETS[fleet].map((kind) => ({ kind, size: SHIPS[kind].size, at: null, across: true }));
97
+ /** A whole fleet laid out at random, by the rules; null if it couldn't be done (the fleet is too big for the grid). */
98
+ export function randomFleet(rules, rand, tries = 200) {
99
+ const n = rules.size;
100
+ for (let attempt = 0; attempt < tries; attempt++) {
101
+ const ships = emptyFleet(rules.fleet);
102
+ let ok = true;
103
+ for (const ship of ships) {
104
+ const blocked = blockedBy(n, ships, rules.touch);
105
+ const spots = [true, false].flatMap((across) => bowsFor(n, ship.size, across, blocked).map((at) => ({ at, across })));
106
+ if (!spots.length) {
107
+ ok = false;
108
+ break;
109
+ }
110
+ Object.assign(ship, pickRandom(spots, rand));
111
+ }
112
+ if (ok)
113
+ return ships;
114
+ }
115
+ return null;
116
+ }
117
+ // ---- A game -------------------------------------------------------------------------------------
118
+ function startRound(s) {
119
+ const cells = s.rules.size * s.rules.size;
120
+ s.fleets = [emptyFleet(s.rules.fleet), emptyFleet(s.rules.fleet)];
121
+ s.across = [true, true];
122
+ s.set = [false, false];
123
+ s.shots = [Array(cells).fill(false), Array(cells).fill(false)];
124
+ s.aims = [[], []];
125
+ s.lastShots = [[], []];
126
+ s.turn = s.first;
127
+ s.roundWinner = null;
128
+ s.phase = "placing";
129
+ s.ready = [false, false];
130
+ s.narration = null;
131
+ }
132
+ export function newCombat(rules, seats, seed) {
133
+ const s = { game: "combat", rules, seats: 2, rng: seed >>> 0, round: 1, wins: [0, 0] };
134
+ s.first = rules.first === "seat1" ? 0 : rules.first === "seat2" ? 1 : nextRandom(s)() < 0.5 ? 0 : 1;
135
+ startRound(s);
136
+ return s;
137
+ }
138
+ const sunk = (n, ship, shots) => cellsOf(n, ship).every((c) => shots[c]);
139
+ /** How many of a seat's ships are still afloat. */
140
+ const afloat = (s, seat) => s.fleets[seat].filter((ship) => !sunk(s.rules.size, ship, s.shots[other(seat)])).length;
141
+ function matchOver(s) {
142
+ const left = s.rules.games - s.round;
143
+ return left <= 0 || Math.abs(s.wins[0] - s.wins[1]) > left;
144
+ }
145
+ const COLS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
146
+ const coordsFor = (n) => ({ cols: Array.from({ length: n }, (_, c) => COLS[c]), rows: Array.from({ length: n }, (_, r) => String(r + 1)) });
147
+ const nameOf = (n, cell) => cellName({ cols: n, coords: coordsFor(n) }, cell);
148
+ const listed = (words) => (words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`);
149
+ function seen(s, me) {
150
+ const n = s.rules.size;
151
+ const them = other(me);
152
+ const theirs = s.fleets[them];
153
+ const fired = s.shots[me];
154
+ const over = s.phase === "scored" || s.phase === "finished";
155
+ const covered = new Set(theirs.flatMap((ship) => cellsOf(n, ship)));
156
+ return {
157
+ rules: s.rules,
158
+ me,
159
+ phase: s.phase,
160
+ round: s.round,
161
+ first: s.first,
162
+ turn: s.turn,
163
+ wins: s.wins,
164
+ roundWinner: s.roundWinner,
165
+ set: s.set,
166
+ ready: s.ready,
167
+ narration: s.narration,
168
+ across: s.across[me],
169
+ mine: s.fleets[me],
170
+ incoming: s.shots[them],
171
+ lastIn: s.lastShots[them],
172
+ out: fired.map((shot, cell) => (!shot ? null : covered.has(cell) ? "hit" : "miss")),
173
+ lastOut: s.lastShots[me],
174
+ aims: s.aims[me],
175
+ known: theirs.map((ship) => ({ ship, sunk: sunk(n, ship, fired) })).filter((k) => over || (s.rules.announce && k.sunk)),
176
+ fresh: !s.shots.some((side) => side.some(Boolean)),
177
+ };
178
+ }
179
+ /** Water that can't hold a ship: round a ship you know is sunk, when ships can't touch. */
180
+ function ruledOut(k) {
181
+ const out = new Set();
182
+ if (k.rules.touch)
183
+ return out;
184
+ for (const { ship, sunk: down } of k.known)
185
+ if (down)
186
+ for (const cell of cellsOf(k.rules.size, ship))
187
+ for (const c of around(k.rules.size, cell))
188
+ if (k.out[c] === null)
189
+ out.add(c);
190
+ return out;
191
+ }
192
+ /** The cells I can still shoot at. */
193
+ function openCells(k) {
194
+ const no = ruledOut(k);
195
+ return k.out.flatMap((o, cell) => (o === null && !no.has(cell) ? [cell] : []));
196
+ }
197
+ const myAfloat = (k) => k.mine.filter((ship) => !sunk(k.rules.size, ship, k.incoming)).length;
198
+ /** Salvo: how many shots my volley is: one per ship I have afloat, or every cell that's left if that's fewer. */
199
+ const volley = (k) => Math.min(myAfloat(k), openCells(k).length);
200
+ /** Placing: where each of my ships can go now. Ships in hand by kind, ships on the grid by their bow. */
201
+ function placings(k) {
202
+ const n = k.rules.size;
203
+ const sources = [];
204
+ const kinds = new Set();
205
+ for (const ship of k.mine) {
206
+ if (ship.at === null) {
207
+ if (kinds.has(ship.kind))
208
+ continue;
209
+ kinds.add(ship.kind);
210
+ sources.push({ from: ship.kind, to: bowsFor(n, ship.size, k.across, blockedBy(n, k.mine, k.rules.touch)) });
211
+ }
212
+ else {
213
+ // A ship already down can go anywhere it fits with itself out of the way, except just where it is.
214
+ const to = bowsFor(n, ship.size, k.across, blockedBy(n, k.mine, k.rules.touch, ship)).filter((at) => at !== ship.at || k.across !== ship.across);
215
+ sources.push({ from: ship.at, to });
216
+ }
217
+ }
218
+ return sources.filter((s) => s.to.length > 0);
219
+ }
220
+ // ---- Moves --------------------------------------------------------------------------------------
221
+ function onGrid(move, id) {
222
+ if (move.grid !== undefined && move.grid !== id)
223
+ throw new GameError("That's the wrong board.");
224
+ }
225
+ function cellOf(s, value) {
226
+ const cell = typeof value === "number" ? value : NaN;
227
+ if (!Number.isInteger(cell) || cell < 0 || cell >= s.rules.size * s.rules.size)
228
+ throw new GameError("That's not on the board.");
229
+ return cell;
230
+ }
231
+ function place(state, seat, move) {
232
+ if (state.phase !== "placing")
233
+ throw new GameError("The ships are already placed.");
234
+ if (state.set[seat])
235
+ throw new GameError("You've said you're ready.");
236
+ const s = clone(state);
237
+ const n = s.rules.size;
238
+ const fleet = s.fleets[seat];
239
+ if (move.type === "turn") {
240
+ s.across[seat] = !s.across[seat];
241
+ return s;
242
+ }
243
+ if (move.type === "auto") {
244
+ const laid = randomFleet(s.rules, nextRandom(s));
245
+ if (!laid)
246
+ throw new GameError("That fleet doesn't fit on this board.");
247
+ s.fleets[seat] = laid;
248
+ return s;
249
+ }
250
+ if (move.type === "ready") {
251
+ if (fleet.some((ship) => ship.at === null))
252
+ throw new GameError("Place all your ships first.");
253
+ // Each ship was checked as it went down; check the whole fleet once more before it's final.
254
+ if (!fleetIsLegal(s.rules, fleet))
255
+ throw new GameError("Those ships aren't placed by the rules.");
256
+ s.set[seat] = true;
257
+ if (s.set.every(Boolean)) {
258
+ s.phase = "shooting";
259
+ s.turn = s.first;
260
+ }
261
+ return s;
262
+ }
263
+ if (move.type !== "place")
264
+ throw new GameError("That's not a move in Combat.");
265
+ onGrid(move, "mine");
266
+ const to = cellOf(s, move.to);
267
+ const ship = typeof move.from === "string" ? fleet.find((sh) => sh.at === null && sh.kind === move.from) : fleet.find((sh) => cellsOf(n, sh).includes(move.from));
268
+ if (!ship)
269
+ throw new GameError(typeof move.from === "string" ? "You have no ship like that left to place." : "There's no ship there.");
270
+ const across = s.across[seat];
271
+ if (ship.at === to && ship.across === across)
272
+ throw new GameError("It's already there.");
273
+ const cells = shipCells(n, ship.size, to, across);
274
+ if (!cells)
275
+ throw new GameError("It doesn't fit there: it would run off the board.");
276
+ const blocked = blockedBy(n, fleet, true, ship);
277
+ if (cells.some((c) => blocked.has(c)))
278
+ throw new GameError("There's a ship in the way.");
279
+ if (!s.rules.touch) {
280
+ const near = blockedBy(n, fleet, false, ship);
281
+ if (cells.some((c) => near.has(c)))
282
+ throw new GameError("Ships can't touch, even at a corner.");
283
+ }
284
+ ship.at = to;
285
+ ship.across = across;
286
+ return s;
287
+ }
288
+ /** Land shots on the other seat's grid and say what they did. Changes `s`: callers pass a copy. */
289
+ function land(s, seat, cells) {
290
+ const n = s.rules.size;
291
+ const them = other(seat);
292
+ const theirs = s.fleets[them];
293
+ const before = theirs.map((ship) => sunk(n, ship, s.shots[seat]));
294
+ for (const cell of cells)
295
+ s.shots[seat][cell] = true;
296
+ s.lastShots[seat] = cells;
297
+ s.aims[seat] = [];
298
+ const hits = cells.filter((cell) => theirs.some((ship) => cellsOf(n, ship).includes(cell))).length;
299
+ const down = theirs.filter((ship, i) => !before[i] && sunk(n, ship, s.shots[seat]));
300
+ const many = s.rules.games > 1;
301
+ if (afloat(s, them) === 0) {
302
+ s.wins[seat] += 1;
303
+ s.roundWinner = seat;
304
+ s.narration = `${who(seat)} sinks the last of ${who(them)}'s fleet${many ? ` and wins game ${s.round}` : ""}!`;
305
+ s.phase = matchOver(s) ? "finished" : "scored";
306
+ return;
307
+ }
308
+ const what = cells.length === 1
309
+ ? `${who(seat)} fires at ${nameOf(n, cells[0])}: ${hits ? "hit!" : "miss"}`
310
+ : `${who(seat)} fires ${cells.length} shots: ${hits === 0 ? "all miss" : `${hits} hit${hits === 1 ? "" : "s"}!`}`;
311
+ // The old cry, in the other player's mouth. Without the announce rule a sinking is only a hit.
312
+ const cry = s.rules.announce && down.length ? ` ${who(them)} says: “You sank my ${listed(down.map((ship) => shipName(ship.kind)))}!”` : "";
313
+ const again = s.rules.again && !s.rules.salvo && hits > 0;
314
+ s.narration = what + cry;
315
+ if (!again)
316
+ s.turn = them;
317
+ }
318
+ function apply(state, seat, move) {
319
+ if (seat !== 0 && seat !== 1)
320
+ throw new GameError("You're not in this game.");
321
+ if (move?.type === "next") {
322
+ if (state.phase !== "scored")
323
+ throw new GameError("There's a game going on.");
324
+ const s = clone(state);
325
+ if (markReady(s.ready, seat)) {
326
+ // Take turns shooting first.
327
+ s.round += 1;
328
+ s.first = other(s.first);
329
+ startRound(s);
330
+ }
331
+ return s;
332
+ }
333
+ if (move?.type === "place" || move?.type === "turn" || move?.type === "auto" || move?.type === "ready")
334
+ return place(state, seat, move);
335
+ if (move?.type !== "shoot" && move?.type !== "aim" && move?.type !== "fire")
336
+ throw new GameError("That's not a move in Combat.");
337
+ if (state.phase === "placing")
338
+ throw new GameError("Not until both fleets are placed.");
339
+ if (state.phase !== "shooting")
340
+ throw new GameError("This game is over.");
341
+ if (state.turn !== seat)
342
+ throw new GameError("It's not your turn.");
343
+ const k = seen(state, seat);
344
+ if (move.type === "fire") {
345
+ if (!state.rules.salvo)
346
+ throw new GameError("Tap a square to shoot.");
347
+ const due = volley(k);
348
+ if (state.aims[seat].length < due)
349
+ throw new GameError(`Mark ${due} squares first.`);
350
+ const s = clone(state);
351
+ land(s, seat, [...s.aims[seat]]);
352
+ return s;
353
+ }
354
+ onGrid(move, "theirs");
355
+ const cell = cellOf(state, move.cell);
356
+ if (state.rules.salvo !== (move.type === "aim"))
357
+ throw new GameError(state.rules.salvo ? "Mark your shots, then fire them together." : "Tap a square to shoot.");
358
+ if (move.type === "aim" && state.aims[seat].includes(cell)) {
359
+ // Tapping a marked square takes the mark off.
360
+ const s = clone(state);
361
+ s.aims[seat] = s.aims[seat].filter((c) => c !== cell);
362
+ return s;
363
+ }
364
+ if (state.shots[seat][cell])
365
+ throw new GameError("You've already shot there.");
366
+ if (ruledOut(k).has(cell))
367
+ throw new GameError("No ship can be there: ships never touch.");
368
+ const s = clone(state);
369
+ if (move.type === "aim") {
370
+ if (s.aims[seat].length >= volley(k))
371
+ throw new GameError("That's all your shots marked: fire, or take one back.");
372
+ s.aims[seat].push(cell);
373
+ return s;
374
+ }
375
+ land(s, seat, [cell]);
376
+ return s;
377
+ }
378
+ /** Both seats when the match is level, which only a game drawn by agreement can leave it. */
379
+ const winners = (s) => (s.wins[0] === s.wins[1] ? [0, 1] : [s.wins[0] > s.wins[1] ? 0 : 1]);
380
+ // ---- What a seat sees ---------------------------------------------------------------------------
381
+ const MISS = { owner: null, kind: "miss", glyph: "●", label: "miss", tone: "light" };
382
+ const HIT = { owner: null, kind: "hit", glyph: "✕", label: "hit", tone: "red" };
383
+ const AIM = { owner: null, kind: "aim", glyph: "◎", label: "marked to fire", tone: "gold" };
384
+ const CLEAR = { owner: null, kind: "clear", glyph: "·", label: "no ship can be here", tone: "dark", faded: true };
385
+ /** One square of a ship: whole, hit, or part of a sunk ship. The squares of one ship share a `span`, so the table joins them into a hull. */
386
+ function hull(owner, ship, span, shot, down) {
387
+ const name = shipName(ship.kind);
388
+ if (down)
389
+ return { owner, kind: "sunk", glyph: "✕", label: `sunk ${name}`, tone: "red", span };
390
+ if (shot)
391
+ return { owner, kind: "hit", glyph: "✕", label: `${name}, hit`, tone: "red", span };
392
+ return { owner, kind: "ship", glyph: "■", label: name, tone: "dark", span };
393
+ }
394
+ /** A ship off the board, for a reserve: a block per square of it. */
395
+ const inHand = (owner, kind) => ({ owner, kind, glyph: "■".repeat(SHIPS[kind].size), label: `${shipName(kind)}, ${SHIPS[kind].size} long`, tone: "dark" });
396
+ function reserve(owner, label, kinds) {
397
+ const counts = new Map();
398
+ for (const kind of kinds)
399
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
400
+ return { owner, label, pieces: [...counts].map(([kind, count]) => ({ piece: inHand(owner, kind), count })) };
401
+ }
402
+ function render(k) {
403
+ const { rules, me } = k;
404
+ const n = rules.size;
405
+ const them = other(me);
406
+ const many = rules.games > 1;
407
+ const over = k.phase === "finished";
408
+ const done = over || k.phase === "scored";
409
+ const myTurn = k.phase === "shooting" && k.turn === me;
410
+ const base = { rows: n, cols: n, coords: coordsFor(n), shade: Array(n * n).fill("water") };
411
+ // My own grid: my ships, and where the other player has shot.
412
+ const mine = { id: "mine", label: "Your fleet", owner: me, ...base, cells: k.incoming.map((shot) => (shot ? MISS : null)), marks: k.lastIn.map((cell) => ({ cell, mark: "last" })) };
413
+ k.mine.forEach((ship, i) => {
414
+ const down = sunk(n, ship, k.incoming);
415
+ for (const cell of cellsOf(n, ship))
416
+ mine.cells[cell] = hull(me, ship, `ship-${i}`, k.incoming[cell], down);
417
+ });
418
+ // Theirs: only what my shots have told me, until the game is over.
419
+ const no = ruledOut(k);
420
+ const theirs = {
421
+ id: "theirs",
422
+ label: `${who(them)}'s waters`,
423
+ ...base,
424
+ cells: k.out.map((o, cell) => (o === "hit" ? HIT : o === "miss" ? MISS : k.aims.includes(cell) ? AIM : no.has(cell) && !done ? CLEAR : null)),
425
+ marks: k.lastOut.map((cell) => ({ cell, mark: "last" })),
426
+ };
427
+ for (const { ship, sunk: down } of k.known)
428
+ for (const cell of cellsOf(n, ship))
429
+ theirs.cells[cell] = { ...hull(them, ship, `theirs-${ship.at}`, k.out[cell] !== null, down), ...(k.out[cell] === null ? { faded: true } : {}) };
430
+ const info = [...(rules.touch ? [] : ["Ships can't touch"]), ...(rules.salvo ? ["Salvo: a shot for each ship you have afloat"] : rules.again ? ["A hit earns another shot"] : []), ...(many ? [`Game ${k.round} of ${rules.games}`] : [])];
431
+ const wins = (seat) => (many ? `${k.wins[seat]} win${k.wins[seat] === 1 ? "" : "s"}` : "");
432
+ const scores = k.wins.map((value, i) => ({ seats: [i], value }));
433
+ const common = { game: "combat", seats: 2, me, teams: false, phase: k.phase, scores, info };
434
+ if (k.phase === "placing") {
435
+ const sources = k.set[me] ? [] : placings(k);
436
+ const inMyHand = k.mine.filter((ship) => ship.at === null);
437
+ // The next ship to place is picked up for you: the first in hand that has somewhere to go.
438
+ const next = sources.find((s) => typeof s.from === "string");
439
+ if (sources.length)
440
+ mine.select = { type: "place", sources, ...(next ? { suggest: next.from } : {}) };
441
+ const options = [
442
+ { label: k.across ? "Turn ships ↕ (now across)" : "Turn ships ↔ (now down)", move: { type: "turn" }, hint: "Which way the next ship you put down will lie" },
443
+ { label: inMyHand.length === k.mine.length ? "Place them for me" : "Shuffle them for me", move: { type: "auto" }, hint: "Lay the whole fleet out at random" },
444
+ ...(inMyHand.length ? [] : [{ label: "Ready", move: { type: "ready" }, primary: true }]),
445
+ ];
446
+ const stuck = inMyHand.length > 0 && !next;
447
+ return {
448
+ ...common,
449
+ turn: null,
450
+ grids: [mine],
451
+ reserves: inMyHand.length ? [reserve(me, "To place", inMyHand.map((ship) => ship.kind))] : [],
452
+ choices: k.set[me] ? null : { prompt: "", layout: "row", options },
453
+ // All anyone is told about the other player's grid: still placing, or ready.
454
+ players: k.set.map((ready, i) => ({ line: ready ? "Ready" : "Placing ships", badge: wins(i) || undefined })),
455
+ narration: k.set[me]
456
+ ? `Your fleet is ready. Waiting for ${who(them)}…`
457
+ : stuck
458
+ ? "That ship doesn't fit lying this way: turn it, or move another ship"
459
+ : inMyHand.length
460
+ ? `Place your ${shipName(inMyHand[0].kind)} (${inMyHand[0].size} long): tap where its bow goes`
461
+ : "Tap a ship to move it, or say you're ready",
462
+ result: null,
463
+ over: null,
464
+ };
465
+ }
466
+ const due = rules.salvo && myTurn ? volley(k) : 0;
467
+ const full = rules.salvo && myTurn && k.aims.length >= due;
468
+ if (myTurn)
469
+ theirs.tap = rules.salvo ? { type: "aim", cells: full ? [...k.aims] : openCells(k) } : { type: "shoot", cells: openCells(k) };
470
+ // What's left of each fleet. Without the announce rule nobody is told what they've sunk, so the other fleet is listed whole.
471
+ const left = (ships, shots) => ships.filter((ship) => !sunk(n, ship, shots)).map((ship) => ship.kind);
472
+ const sunkKinds = k.known.filter((x) => x.sunk).map((x) => x.ship.kind);
473
+ const theirKinds = [...FLEETS[rules.fleet]];
474
+ for (const kind of sunkKinds)
475
+ theirKinds.splice(theirKinds.indexOf(kind), 1);
476
+ const scoreTitle = k.roundWinner === null ? (done ? `Game ${k.round} is a draw` : "") : `${who(k.roundWinner)} wins game ${k.round}`;
477
+ const w = winners(k);
478
+ return {
479
+ ...common,
480
+ turn: k.phase === "shooting" ? k.turn : null,
481
+ grids: [theirs, mine],
482
+ reserves: [reserve(them, rules.announce || done ? "Afloat" : "Fleet", theirKinds), reserve(me, "Afloat", left(k.mine, k.incoming))],
483
+ choices: k.phase === "scored" ? nextHandChoices(k.ready, me, false, "Next game") : full ? { prompt: "", options: [{ label: due === 1 ? "Fire!" : `Fire all ${due}!`, move: { type: "fire" }, primary: true }] } : null,
484
+ players: k.wins.map((_, i) => ({ line: wins(i), badge: k.phase === "shooting" && k.fresh && i === k.first ? "Shoots first" : undefined })),
485
+ info: [...info, ...(rules.salvo && myTurn ? [`Shots marked: ${k.aims.length} of ${due}`] : [])],
486
+ narration: k.narration ?? (myTurn ? (rules.salvo ? `You shoot first: mark ${due} squares, then fire` : "You shoot first: tap a square") : null),
487
+ result: many && done ? { key: `game-${k.round}`, title: scoreTitle, sides: [[0], [1]], rows: [{ label: "Wins", values: k.wins.map(String), tone: "total" }] } : null,
488
+ over: over ? { winners: w, title: w.length > 1 ? "It's a draw!" : `${who(w[0])} wins${many ? ` ${k.wins[w[0]]} to ${k.wins[other(w[0])]}` : ""}!` } : null,
489
+ };
490
+ }
491
+ const view = (s, seat) => render(seen(s, seat));
492
+ // ---- Computer players ---------------------------------------------------------------------------
493
+ // A computer player is handed `seen` like the table is, so it can't know more than a person in
494
+ // its seat would.
495
+ //
496
+ // Placing: one ship at a time, like a person. A careless player drops them anywhere; a careful one
497
+ // keeps them apart, since ships side by side are found together.
498
+ //
499
+ // Shooting is hunt and target. With a hit on a ship that's still afloat, it targets: every way a
500
+ // ship that's left could lie through the hits is counted, and lines through two hits count for far
501
+ // more than lines through one, so it works along a ship until it's sunk. With nothing to chase, it
502
+ // hunts: at top skill by counting, for every open square, how many ways the ships that are left
503
+ // could still cover it; otherwise on a checkerboard as wide as the smallest ship left, which can't
504
+ // miss a ship. A careless shot (see `careless`) forgets all that and lands anywhere.
505
+ const skillOf = (style) => style.skill ?? 1;
506
+ function botPlace(k, style, rand) {
507
+ if (k.set[k.me])
508
+ return null;
509
+ const n = k.rules.size;
510
+ const next = k.mine.find((ship) => ship.at === null);
511
+ if (!next)
512
+ return { type: "ready" };
513
+ const blocked = blockedBy(n, k.mine, k.rules.touch);
514
+ const here = bowsFor(n, next.size, k.across, blocked);
515
+ const turned = bowsFor(n, next.size, !k.across, blocked);
516
+ // Boxed in: start again. Otherwise turn the ship about half the time, so fleets lie both ways.
517
+ if (!here.length)
518
+ return turned.length ? { type: "turn" } : { type: "auto" };
519
+ if (turned.length && rand() < 0.4)
520
+ return { type: "turn" };
521
+ if (careless(style, rand))
522
+ return { type: "place", from: next.kind, to: pickRandom(here, rand) };
523
+ // How many squares of other ships a ship here would lie against.
524
+ const taken = new Set(k.mine.flatMap((ship) => cellsOf(n, ship)));
525
+ const crowd = (at) => new Set(shipCells(n, next.size, at, k.across).flatMap((c) => around(n, c)).filter((c) => taken.has(c))).size;
526
+ const least = Math.min(...here.map(crowd));
527
+ return { type: "place", from: next.kind, to: pickRandom(here.filter((at) => crowd(at) === least), rand) };
528
+ }
529
+ /** The hits I'm still chasing: not part of a ship I know is sunk. */
530
+ function liveHits(k) {
531
+ const n = k.rules.size;
532
+ const dead = new Set(k.known.filter((x) => x.sunk).flatMap((x) => cellsOf(n, x.ship)));
533
+ const hits = k.out.flatMap((o, cell) => (o === "hit" && !dead.has(cell) ? [cell] : []));
534
+ if (k.rules.announce)
535
+ return hits;
536
+ // Nobody says when a ship is sunk, so work it out as a person would: a straight run of hits with
537
+ // no open water at either end, as long as some ship, is taken to be a ship that's gone.
538
+ const sizes = new Set(fleetSizes(k.rules.fleet));
539
+ const hit = (cell) => cell !== null && k.out[cell] === "hit";
540
+ const open = (cell) => cell !== null && k.out[cell] === null;
541
+ const step = (cell, dr, dc) => {
542
+ const r = rowOf(n, cell) + dr;
543
+ const c = colOf(n, cell) + dc;
544
+ return r >= 0 && r < n && c >= 0 && c < n ? cellAt(n, r, c) : null;
545
+ };
546
+ return hits.filter((cell) => {
547
+ for (const [dr, dc] of [[0, 1], [1, 0]]) {
548
+ let a = cell;
549
+ let b = cell;
550
+ let length = 1;
551
+ for (let p = step(a, -dr, -dc); hit(p); p = step(a, -dr, -dc))
552
+ [a, length] = [p, length + 1];
553
+ for (let p = step(b, dr, dc); hit(p); p = step(b, dr, dc))
554
+ [b, length] = [p, length + 1];
555
+ if (length > 1 && sizes.has(length) && !open(step(a, -dr, -dc)) && !open(step(b, dr, dc)))
556
+ return false;
557
+ }
558
+ // A hit on its own is finished with once there's no open water beside it.
559
+ return beside(n, cell).some((c) => k.out[c] === null);
560
+ });
561
+ }
562
+ /** The best square to shoot next, counting `aims` (the rest of a volley) as already gone. */
563
+ function botShot(k, style, rand) {
564
+ const n = k.rules.size;
565
+ const open = openCells(k).filter((cell) => !k.aims.includes(cell));
566
+ if (!open.length)
567
+ return null;
568
+ if (careless(style, rand))
569
+ return pickRandom(open, rand);
570
+ const skill = skillOf(style);
571
+ const canHold = new Set(open);
572
+ const live = new Set(liveHits(k));
573
+ if (!k.rules.touch) {
574
+ // Ships never touch, so the squares at the corners of a hit are water.
575
+ for (const cell of live)
576
+ for (const c of around(n, cell))
577
+ if (rowOf(n, c) !== rowOf(n, cell) && colOf(n, c) !== colOf(n, cell))
578
+ canHold.delete(c);
579
+ }
580
+ // The ships still to find. When sinkings aren't announced, that's all of them, as far as I'm told.
581
+ const sizes = fleetSizes(k.rules.fleet);
582
+ for (const x of k.known)
583
+ if (x.sunk)
584
+ sizes.splice(sizes.indexOf(x.ship.size), 1);
585
+ const best = (score) => {
586
+ let top = -1;
587
+ let cell = null;
588
+ for (const c of canHold) {
589
+ const value = score[c] + rand() * 0.5;
590
+ if (score[c] > 0 && value > top)
591
+ [top, cell] = [value, c];
592
+ }
593
+ return cell;
594
+ };
595
+ /** For every open square, the ways a ship that's left could cover it; `chasing` counts only ways through a live hit. */
596
+ const count = (chasing) => {
597
+ const score = Array(n * n).fill(0);
598
+ for (const size of sizes) {
599
+ for (const across of size === 1 ? [true] : [true, false]) {
600
+ for (let at = 0; at < n * n; at++) {
601
+ const cells = shipCells(n, size, at, across);
602
+ if (!cells || !cells.every((c) => canHold.has(c) || live.has(c)))
603
+ continue;
604
+ const through = cells.filter((c) => live.has(c)).length;
605
+ if (chasing ? through === 0 : through > 0)
606
+ continue;
607
+ // Two hits in a line say far more about where a ship lies than one.
608
+ const weight = 12 ** through;
609
+ for (const c of cells)
610
+ if (!live.has(c))
611
+ score[c] += weight;
612
+ }
613
+ }
614
+ }
615
+ return score;
616
+ };
617
+ if (live.size) {
618
+ const beside1 = [...new Set([...live].flatMap((cell) => beside(n, cell)))].filter((c) => canHold.has(c));
619
+ // A middling player knows to shoot next to a hit, but not which side.
620
+ const cell = skill >= 0.5 ? best(count(true)) : beside1.length ? pickRandom(beside1, rand) : null;
621
+ if (cell !== null)
622
+ return cell;
623
+ }
624
+ if (skill >= 0.9) {
625
+ const cell = best(count(false));
626
+ if (cell !== null)
627
+ return cell;
628
+ }
629
+ // A checkerboard as wide as the smallest ship left. Keep to the colour already most shot at, so
630
+ // the pattern holds from one turn to the next without having to remember it.
631
+ const gap = Math.max(1, Math.min(...sizes, n));
632
+ const lane = (cell) => (rowOf(n, cell) + colOf(n, cell)) % gap;
633
+ const used = Array(gap).fill(0);
634
+ k.out.forEach((o, cell) => o !== null && used[lane(cell)]++);
635
+ for (const cell of k.aims)
636
+ used[lane(cell)]++;
637
+ const lanes = [...used.keys()].sort((a, b) => used[b] - used[a]);
638
+ const pool = [...canHold];
639
+ for (const l of lanes) {
640
+ const cells = pool.filter((cell) => lane(cell) === l);
641
+ if (cells.length)
642
+ return pickRandom(cells, rand);
643
+ }
644
+ return pickRandom(pool.length ? pool : open, rand);
645
+ }
646
+ function decide(k, style, rand) {
647
+ if (k.phase === "scored")
648
+ return k.ready[k.me] ? null : { type: "next" };
649
+ if (k.phase === "placing")
650
+ return botPlace(k, style, rand);
651
+ if (k.phase !== "shooting" || k.turn !== k.me)
652
+ return null;
653
+ if (!k.rules.salvo) {
654
+ const cell = botShot(k, style, rand);
655
+ return cell === null ? null : { type: "shoot", cell, grid: "theirs" };
656
+ }
657
+ if (k.aims.length >= volley(k))
658
+ return { type: "fire" };
659
+ // A volley is marked a square at a time; each mark counts as gone when choosing the next, which
660
+ // spreads the shots: both ends of a ship being chased, then different parts of the sea.
661
+ const cell = botShot(k, style, rand);
662
+ return cell === null ? null : { type: "aim", cell, grid: "theirs" };
663
+ }
664
+ const bot = (s, seat, style, rand = Math.random) => decide(seen(s, seat), style, rand);
665
+ // ---- The game -----------------------------------------------------------------------------------
666
+ function normalize(rules) {
667
+ const given = (rules && typeof rules === "object" ? rules : {});
668
+ const base = PRESETS.find((p) => p.id === given.id) ?? classicCombat;
669
+ const r = { ...base, ...given };
670
+ const salvo = !!r.salvo;
671
+ return {
672
+ id: String(r.id),
673
+ name: String(r.name),
674
+ size: Math.min(12, Math.max(6, Math.round(Number(r.size)) || 10)),
675
+ fleet: r.fleet === "admirals" || r.fleet === "quick" ? r.fleet : "classic",
676
+ touch: !!r.touch,
677
+ salvo,
678
+ // A volley is already several shots: a hit doesn't earn more.
679
+ again: !salvo && !!r.again,
680
+ announce: !!r.announce,
681
+ first: r.first === "seat1" || r.first === "seat2" ? r.first : "random",
682
+ games: [1, 3, 5, 7].includes(Number(r.games)) ? Number(r.games) : 1,
683
+ };
684
+ }
685
+ const NUMBER = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
686
+ function fleetWords(fleet) {
687
+ const counts = new Map();
688
+ for (const kind of FLEETS[fleet])
689
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
690
+ return listed([...counts].map(([kind, count]) => `${count === 1 ? "a" : NUMBER[count]} ${shipName(kind).toLowerCase()}${count === 1 ? "" : "s"} (${SHIPS[kind].size})`));
691
+ }
692
+ const rules = {
693
+ id: "combat",
694
+ name: "Combat",
695
+ emoji: "🚢",
696
+ blurb: "Hide your fleet, hunt theirs",
697
+ tagline: "Lay out your ships where nobody can see, then take turns calling shots. First to sink the other fleet wins.",
698
+ seats: { min: 2, max: 2, default: 2 },
699
+ teams: false,
700
+ presets: PRESETS,
701
+ fields: [
702
+ { key: "size", label: "Board size", kind: "choice", options: SIZES.map((n) => [n, `${n}×${n}`]) },
703
+ { key: "fleet", label: "Fleet", kind: "choice", options: [["classic", "Classic: 5, 4, 3, 3, 2"], ["admirals", "Admirals: 4, 3, 3, 2, 2, 2, 1, 1, 1, 1"], ["quick", "Quick: 3, 2, 2"]] },
704
+ { key: "touch", label: "Ships may touch", hint: "When off, ships can't lie side by side or corner to corner", kind: "toggle" },
705
+ { key: "salvo", label: "Salvo", hint: "Each turn, a shot for every ship you still have afloat, all marked before any lands", kind: "toggle" },
706
+ { key: "again", label: "A hit earns another shot", hint: "Not in Salvo", kind: "toggle" },
707
+ { key: "announce", label: "Say what was sunk", hint: "When off, a hit is only ever a hit: nobody is told when a ship goes down", kind: "toggle" },
708
+ { key: "first", label: "Who shoots first", kind: "choice", options: [["random", "Luck of the draw"], ["seat1", "The first seat"], ["seat2", "The second seat"]] },
709
+ { key: "games", label: "Games in a match", kind: "choice", options: [[1, "Just one"], [3, "Three"], [5, "Five"], [7, "Seven"]] },
710
+ ],
711
+ normalize,
712
+ validate(r) {
713
+ // Laying one out is the test (always the same way, so the same rules always give the same answer).
714
+ return randomFleet(r, nextRandom({ rng: 1 }), 60) ? [] : [`That fleet doesn't fit on a ${r.size}×${r.size} board${r.touch ? "" : " without ships touching"}.`];
715
+ },
716
+ describe(r) {
717
+ return [
718
+ `Each player hides a fleet on their own ${r.size}×${r.size} grid: ${fleetWords(r.fleet)}. Ships lie in a straight line, across or down.`,
719
+ r.touch ? "Ships may lie right next to each other, but never on top of one another." : "Ships can't touch each other, not even at a corner.",
720
+ "Both players place their ships at the same time. Nobody sees the other's grid.",
721
+ r.salvo
722
+ ? "Then take turns firing a volley: one shot for every ship you still have afloat. Mark them all, then fire them together."
723
+ : `Then take turns calling a shot at one square of the other grid: hit or miss.${r.again ? " A hit earns another shot." : ""}`,
724
+ r.announce ? "A ship is sunk when every square of it has been hit, and its owner says which ship it was." : "A ship is sunk when every square of it has been hit, but nobody says so: a hit is only ever a hit.",
725
+ "The first to sink the whole of the other fleet wins.",
726
+ r.first === "random" ? "Who shoots first is the luck of the draw." : `The ${r.first === "seat1" ? "first" : "second"} seat shoots first.`,
727
+ ...(r.games > 1 ? [`A match is ${r.games} games; most wins takes it. Players take turns shooting first.`] : []),
728
+ ];
729
+ },
730
+ newGame: newCombat,
731
+ apply,
732
+ view,
733
+ bot,
734
+ // Placing is quick, quiet work; a shot gets a moment's thought, a volley's marks less each.
735
+ thinkingTime: (s) => (s.phase === "placing" ? 350 : s.rules.salvo ? 450 : thinkFor(3)),
736
+ summary(s) {
737
+ const over = s.phase === "finished";
738
+ const w = winners(s);
739
+ return {
740
+ game: "combat",
741
+ rules: s.rules.name,
742
+ phase: s.phase,
743
+ over,
744
+ hand: s.round,
745
+ // Only the match score: how many ships each side has left isn't everyone's to know.
746
+ scoreLine: `${s.wins[0]} to ${s.wins[1]}${s.draws ? ` · ${s.draws} drawn` : ""}`,
747
+ // A drawn match lists whoever shot second in the last game first, as the other board games do.
748
+ order: over ? (w.length > 1 ? [other(s.first), s.first] : [w[0], other(w[0])]) : null,
749
+ winners: over ? w : null,
750
+ teams: false,
751
+ seats: s.seats,
752
+ };
753
+ },
754
+ };
755
+ /**
756
+ * Combat, with resigning and draw offers (see offers.ts). No taking back: a shot tells you where a
757
+ * ship is or isn't, and that can't be un-known. `takeBack` is left out, so no earlier position is
758
+ * ever kept.
759
+ */
760
+ export const combat = withOffers(rules, {
761
+ live: (s) => s.phase === "placing" || s.phase === "shooting",
762
+ settle: settleMatch,
763
+ // Only what this seat knows: the hits it has scored against the hits it has taken.
764
+ standing(s, seat) {
765
+ if (s.phase !== "shooting")
766
+ return null;
767
+ const k = seen(s, seat);
768
+ const taken = k.mine.flatMap((ship) => cellsOf(s.rules.size, ship)).filter((cell) => k.incoming[cell]).length;
769
+ return k.out.filter((o) => o === "hit").length - taken;
770
+ },
771
+ });
772
+ //# sourceMappingURL=combat.js.map