@johnmorrisdotca/domino 1.0.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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +8 -0
- package/dist/mexicanTrain/dominoes.d.ts +27 -0
- package/dist/mexicanTrain/dominoes.js +58 -0
- package/dist/mexicanTrain/mexicanTrain.constants.d.ts +42 -0
- package/dist/mexicanTrain/mexicanTrain.constants.js +40 -0
- package/dist/mexicanTrain/mexicanTrain.d.ts +92 -0
- package/dist/mexicanTrain/mexicanTrain.js +339 -0
- package/dist/mexicanTrain/mexicanTrain.types.d.ts +154 -0
- package/dist/mexicanTrain/mexicanTrain.types.js +17 -0
- package/dist/mexicanTrain/trainCodec.d.ts +4 -0
- package/dist/mexicanTrain/trainCodec.js +90 -0
- package/dist/mexicanTrain/trainComputer.d.ts +10 -0
- package/dist/mexicanTrain/trainComputer.js +113 -0
- package/dist/random.d.ts +17 -0
- package/dist/random.js +31 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +85 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Relative, like the rest of lib/party: the browser specs import this, and Playwright resolves no alias.
|
|
2
|
+
import { seededRandom, shuffled } from "../random.js";
|
|
3
|
+
import { everyTile, fits, isDouble, laidAgainst, laidEnds, pipsOf, tileOf } from "./dominoes.js";
|
|
4
|
+
import { TRAIN_DEFAULT_OPTIONS, TRAIN_DOUBLES, TRAIN_MEXICAN, handSizeFor, roundsFor } from "./mexicanTrain.constants.js";
|
|
5
|
+
/**
|
|
6
|
+
* MEXICAN TRAIN, THE DOMINO GAME: the rules, and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* Pure, as the engine is: every function returns a new game and leaves the
|
|
9
|
+
* one it was given untouched. A game is its table (the set, the options, the
|
|
10
|
+
* seed, the seats) and its moves, in order; hands, trains, the boneyard and
|
|
11
|
+
* whose turn it is are always read again from those (`replayTrain`), so a game
|
|
12
|
+
* read back out of a browser's storage is exactly the game its moves make,
|
|
13
|
+
* or none. Every shuffle is drawn from the game's seed and the round's
|
|
14
|
+
* number, so a reload deals exactly what it dealt before.
|
|
15
|
+
*
|
|
16
|
+
* The rules as the site plays them, most published rules' own:
|
|
17
|
+
*
|
|
18
|
+
* - a round is dealt round the engine double, the set's highest in the
|
|
19
|
+
* first round and one fewer each round after, which sits in the hub;
|
|
20
|
+
* - every player has a train of their own out of the hub, and there is one
|
|
21
|
+
* more, the Mexican Train, anybody may play on;
|
|
22
|
+
* - on your turn lay one tile against the open end of your own train, the
|
|
23
|
+
* Mexican Train, or any player's train whose marker is out;
|
|
24
|
+
* - nothing to lay: draw one tile; lay it if it goes, or put your marker out
|
|
25
|
+
* and pass (with nothing to draw, just put it out); lay on your own train
|
|
26
|
+
* and your marker comes in;
|
|
27
|
+
* - a double must be covered before anything else is played anywhere, and
|
|
28
|
+
* whoever lays one lays again to cover it (`DoublesRule` for the house
|
|
29
|
+
* rule that lets doubles be chained);
|
|
30
|
+
* - a round ends when somebody lays their last tile, or when nobody can lay
|
|
31
|
+
* and there is nothing left to draw; every player scores the pips left in
|
|
32
|
+
* their hand, and after the last round the lowest total wins.
|
|
33
|
+
*/
|
|
34
|
+
export const TRAIN_PHASES = { playing: "playing", roundOver: "roundOver", finished: "finished" };
|
|
35
|
+
/** The sets a table may be dealt from, and how many may sit at it. */
|
|
36
|
+
const SPEC = { sizes: [9, 12, 15], fewestPlayers: 2, mostPlayers: 8 };
|
|
37
|
+
/** The longest name a seat keeps. */
|
|
38
|
+
export const TRAIN_NAME_MOST = 20;
|
|
39
|
+
/** A seat's name as given, its spaces tidied and cut to `TRAIN_NAME_MOST` characters. */
|
|
40
|
+
export function cleanTrainName(name) {
|
|
41
|
+
return name.replace(/\s+/g, " ").trim().slice(0, TRAIN_NAME_MOST);
|
|
42
|
+
}
|
|
43
|
+
/** The Mexican Train's number among a game's trains: after every seat's own. */
|
|
44
|
+
export function mexicanOf(game) {
|
|
45
|
+
return game.players.length;
|
|
46
|
+
}
|
|
47
|
+
/** The number a train's next tile must match: its last tile's open end, or the engine double's when nothing is laid on it yet. */
|
|
48
|
+
export function openEnd(game, train) {
|
|
49
|
+
const laid = game.trains[train].laid;
|
|
50
|
+
return laid.length === 0 ? game.engine : laidEnds(laid[laid.length - 1])[1];
|
|
51
|
+
}
|
|
52
|
+
/** A shuffle for this round of this game, the same in every browser: the seed and the round decide it. */
|
|
53
|
+
function shuffleFor(seed, round) {
|
|
54
|
+
return seededRandom((seed ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Deal round `round` of a game at this table: the engine double to the hub,
|
|
58
|
+
* a hand each from the rest, shuffled, and the rest face down to draw from.
|
|
59
|
+
* The first round is led by the first player, and each round after by the
|
|
60
|
+
* next player round the table.
|
|
61
|
+
*/
|
|
62
|
+
function dealRound(table, round) {
|
|
63
|
+
const count = table.players.length;
|
|
64
|
+
const engine = table.set - round;
|
|
65
|
+
const random = shuffleFor(table.seed, round);
|
|
66
|
+
const deck = shuffled(everyTile(table.set).filter((tile) => tile !== tileOf(engine, engine)), random);
|
|
67
|
+
const each = handSizeFor(table.set, count);
|
|
68
|
+
const hands = table.players.map((_, seat) => deck.slice(seat * each, (seat + 1) * each));
|
|
69
|
+
return {
|
|
70
|
+
...table,
|
|
71
|
+
round,
|
|
72
|
+
engine,
|
|
73
|
+
hands,
|
|
74
|
+
trains: [...table.players.map(() => ({ laid: [], open: false })), { laid: [], open: true }],
|
|
75
|
+
boneyard: deck.slice(count * each),
|
|
76
|
+
toPlay: round % count,
|
|
77
|
+
uncovered: [],
|
|
78
|
+
chaining: false,
|
|
79
|
+
drew: false,
|
|
80
|
+
passes: 0,
|
|
81
|
+
turn: table.turn + (round === 0 ? 0 : 1),
|
|
82
|
+
phase: TRAIN_PHASES.playing,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A new game: the set (its highest double), the names at the table (one a
|
|
87
|
+
* seat), which seats a computer plays, the options, and the seed every
|
|
88
|
+
* shuffle is drawn from. Null for a table the game is not offered for — a set
|
|
89
|
+
* not in `PARTY_SPECS`, or too few or too many players — rather than a game
|
|
90
|
+
* nobody chose.
|
|
91
|
+
*/
|
|
92
|
+
export function startTrain(set, players, seed = 1, options = TRAIN_DEFAULT_OPTIONS, computers = players.map(() => false)) {
|
|
93
|
+
if (!SPEC.sizes.includes(set))
|
|
94
|
+
return null;
|
|
95
|
+
if (players.length < SPEC.fewestPlayers || players.length > SPEC.mostPlayers)
|
|
96
|
+
return null;
|
|
97
|
+
if (computers.length !== players.length)
|
|
98
|
+
return null;
|
|
99
|
+
if (!Number.isInteger(seed))
|
|
100
|
+
return null;
|
|
101
|
+
const table = {
|
|
102
|
+
set,
|
|
103
|
+
options: { ...options },
|
|
104
|
+
seed,
|
|
105
|
+
players: players.map(cleanTrainName),
|
|
106
|
+
computers: [...computers],
|
|
107
|
+
history: null,
|
|
108
|
+
rounds: roundsFor(set, options.length),
|
|
109
|
+
round: 0,
|
|
110
|
+
engine: set,
|
|
111
|
+
hands: [],
|
|
112
|
+
trains: [],
|
|
113
|
+
boneyard: [],
|
|
114
|
+
toPlay: 0,
|
|
115
|
+
uncovered: [],
|
|
116
|
+
chaining: false,
|
|
117
|
+
drew: false,
|
|
118
|
+
passes: 0,
|
|
119
|
+
turn: 0,
|
|
120
|
+
phase: TRAIN_PHASES.playing,
|
|
121
|
+
results: [],
|
|
122
|
+
winners: [],
|
|
123
|
+
last: null,
|
|
124
|
+
};
|
|
125
|
+
return dealRound(table, 0);
|
|
126
|
+
}
|
|
127
|
+
/** Whether `seat` may lay on this train at all now, before asking whether a tile fits it. */
|
|
128
|
+
function mayLayOn(game, seat, train) {
|
|
129
|
+
if (train === seat)
|
|
130
|
+
return true;
|
|
131
|
+
const mexican = mexicanOf(game);
|
|
132
|
+
if (train === mexican)
|
|
133
|
+
return game.options.mexican !== TRAIN_MEXICAN.ownFirst || game.trains[seat].laid.length > 0;
|
|
134
|
+
return game.trains[train].open;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Every tile the player to move may lay, and where: what `moves` offers, and
|
|
138
|
+
* what the table lights up. While a double is uncovered anywhere the only
|
|
139
|
+
* lay is to cover the last of them, on whoever's train it is — or, under the
|
|
140
|
+
* chained-doubles rule, another double laid by the player still laying them.
|
|
141
|
+
*/
|
|
142
|
+
export function legalPlays(game) {
|
|
143
|
+
if (game.phase !== TRAIN_PHASES.playing)
|
|
144
|
+
return [];
|
|
145
|
+
const seat = game.toPlay;
|
|
146
|
+
const hand = game.hands[seat];
|
|
147
|
+
const plays = [];
|
|
148
|
+
const lastOpen = game.uncovered.length === 0 ? null : game.uncovered[game.uncovered.length - 1];
|
|
149
|
+
if (lastOpen !== null) {
|
|
150
|
+
const end = openEnd(game, lastOpen);
|
|
151
|
+
for (const tile of hand)
|
|
152
|
+
if (fits(tile, end))
|
|
153
|
+
plays.push({ tile, train: lastOpen });
|
|
154
|
+
if (!game.chaining)
|
|
155
|
+
return plays;
|
|
156
|
+
}
|
|
157
|
+
const trains = game.trains.length;
|
|
158
|
+
for (let train = 0; train < trains; train += 1) {
|
|
159
|
+
if (lastOpen !== null && game.uncovered.includes(train))
|
|
160
|
+
continue;
|
|
161
|
+
if (!mayLayOn(game, seat, train))
|
|
162
|
+
continue;
|
|
163
|
+
const end = openEnd(game, train);
|
|
164
|
+
for (const tile of hand) {
|
|
165
|
+
if (lastOpen !== null && !isDouble(tile))
|
|
166
|
+
continue;
|
|
167
|
+
if (fits(tile, end))
|
|
168
|
+
plays.push({ tile, train });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return plays;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether the player to move may lay this tile on this train now: the same
|
|
175
|
+
* answer `legalPlays` gives, for one tile, without listing every other.
|
|
176
|
+
*/
|
|
177
|
+
export function mayLay(game, tile, train) {
|
|
178
|
+
if (game.phase !== TRAIN_PHASES.playing || train < 0 || train >= game.trains.length)
|
|
179
|
+
return false;
|
|
180
|
+
const seat = game.toPlay;
|
|
181
|
+
if (!game.hands[seat].includes(tile) || !fits(tile, openEnd(game, train)))
|
|
182
|
+
return false;
|
|
183
|
+
const lastOpen = game.uncovered.length === 0 ? null : game.uncovered[game.uncovered.length - 1];
|
|
184
|
+
if (lastOpen === train)
|
|
185
|
+
return true;
|
|
186
|
+
if (lastOpen !== null && (!game.chaining || !isDouble(tile) || game.uncovered.includes(train)))
|
|
187
|
+
return false;
|
|
188
|
+
return mayLayOn(game, seat, train);
|
|
189
|
+
}
|
|
190
|
+
/** Whether the player to move has any tile to lay: what decides between laying, drawing and passing. */
|
|
191
|
+
function canLay(game) {
|
|
192
|
+
const hand = game.hands[game.toPlay];
|
|
193
|
+
for (let train = 0; train < game.trains.length; train += 1) {
|
|
194
|
+
const end = openEnd(game, train);
|
|
195
|
+
for (const tile of hand)
|
|
196
|
+
if (fits(tile, end) && mayLay(game, tile, train))
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/** Every move the player to move may make now; none once the game is over. */
|
|
202
|
+
export function trainMoves(game) {
|
|
203
|
+
if (game.phase === TRAIN_PHASES.finished)
|
|
204
|
+
return [];
|
|
205
|
+
if (game.phase === TRAIN_PHASES.roundOver)
|
|
206
|
+
return [{ kind: "next" }];
|
|
207
|
+
const plays = legalPlays(game).map(({ tile, train }) => ({ kind: "play", tile, train }));
|
|
208
|
+
if (plays.length > 0)
|
|
209
|
+
return plays;
|
|
210
|
+
return game.boneyard.length > 0 && !game.drew ? [{ kind: "draw" }] : [{ kind: "pass" }];
|
|
211
|
+
}
|
|
212
|
+
/** Every move a game has made, first to last. */
|
|
213
|
+
export function movesOf(game) {
|
|
214
|
+
const moves = [];
|
|
215
|
+
for (let link = game.history; link !== null; link = link.before)
|
|
216
|
+
moves.push(link.move);
|
|
217
|
+
return moves.reverse();
|
|
218
|
+
}
|
|
219
|
+
/** How many moves a game has made. */
|
|
220
|
+
export function moveCount(game) {
|
|
221
|
+
return game.history?.count ?? 0;
|
|
222
|
+
}
|
|
223
|
+
/** Each seat's total over every round played: the lowest wins. */
|
|
224
|
+
export function trainTotals(game) {
|
|
225
|
+
return game.players.map((_, seat) => game.results.reduce((sum, result) => sum + result.pips[seat], 0));
|
|
226
|
+
}
|
|
227
|
+
/** The pips in a hand, which count against its holder when the round ends. */
|
|
228
|
+
export function handPips(hand) {
|
|
229
|
+
return hand.reduce((sum, tile) => sum + pipsOf(tile), 0);
|
|
230
|
+
}
|
|
231
|
+
/** The round over, scored; the game over too after its last round, its lowest totals the winners. */
|
|
232
|
+
function endRound(game, ending, out) {
|
|
233
|
+
const result = { engine: game.engine, pips: game.hands.map(handPips), ending, out };
|
|
234
|
+
const results = [...game.results, result];
|
|
235
|
+
const finished = results.length >= game.rounds;
|
|
236
|
+
const totals = trainTotals({ players: game.players, results });
|
|
237
|
+
const lowest = Math.min(...totals);
|
|
238
|
+
return {
|
|
239
|
+
...game,
|
|
240
|
+
results,
|
|
241
|
+
uncovered: [],
|
|
242
|
+
chaining: false,
|
|
243
|
+
phase: finished ? TRAIN_PHASES.finished : TRAIN_PHASES.roundOver,
|
|
244
|
+
winners: finished ? totals.flatMap((total, seat) => (total === lowest ? [seat] : [])) : [],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** The turn passes to the next seat round the table. */
|
|
248
|
+
function nextSeat(game) {
|
|
249
|
+
return { ...game, toPlay: (game.toPlay + 1) % game.players.length, turn: game.turn + 1, drew: false, chaining: false };
|
|
250
|
+
}
|
|
251
|
+
/** A tile laid: from the hand to the train, turned to fit, and what it leaves to be done this turn. */
|
|
252
|
+
function lay(game, tile, train) {
|
|
253
|
+
const seat = game.toPlay;
|
|
254
|
+
const end = openEnd(game, train);
|
|
255
|
+
const hands = game.hands.map((hand, at) => (at === seat ? hand.filter((one) => one !== tile) : hand));
|
|
256
|
+
const trains = game.trains.map((one, at) => at === train ? { laid: [...one.laid, laidAgainst(tile, end)], open: at === seat ? false : one.open } : one);
|
|
257
|
+
const covering = game.uncovered.length > 0 && game.uncovered[game.uncovered.length - 1] === train;
|
|
258
|
+
const uncovered = covering ? game.uncovered.slice(0, -1) : game.uncovered;
|
|
259
|
+
const laid = { ...game, hands, trains, uncovered, passes: 0, drew: false };
|
|
260
|
+
if (hands[seat].length === 0)
|
|
261
|
+
return endRound(laid, "domino", seat);
|
|
262
|
+
if (isDouble(tile))
|
|
263
|
+
return { ...laid, uncovered: [...uncovered, train], chaining: game.options.doubles === TRAIN_DOUBLES.chain };
|
|
264
|
+
// Covering one of several doubles: the same player goes on covering the rest.
|
|
265
|
+
if (covering && uncovered.length > 0)
|
|
266
|
+
return { ...laid, chaining: false };
|
|
267
|
+
return nextSeat(laid);
|
|
268
|
+
}
|
|
269
|
+
/** Whether a move is one `trainMoves` offers now, asked of that move alone. */
|
|
270
|
+
function allowedNow(game, move) {
|
|
271
|
+
if (move.kind === "next")
|
|
272
|
+
return game.phase === TRAIN_PHASES.roundOver;
|
|
273
|
+
if (game.phase !== TRAIN_PHASES.playing)
|
|
274
|
+
return false;
|
|
275
|
+
if (move.kind === "play")
|
|
276
|
+
return mayLay(game, move.tile, move.train);
|
|
277
|
+
if (canLay(game))
|
|
278
|
+
return false;
|
|
279
|
+
const mayDraw = game.boneyard.length > 0 && !game.drew;
|
|
280
|
+
return move.kind === "draw" ? mayDraw : !mayDraw;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The game after that move, or null for a move that may not be made now.
|
|
284
|
+
* The game given is left untouched, and the move is added to its record.
|
|
285
|
+
*/
|
|
286
|
+
export function playTrain(game, move) {
|
|
287
|
+
if (!allowedNow(game, move))
|
|
288
|
+
return null;
|
|
289
|
+
const seat = game.toPlay;
|
|
290
|
+
const history = { move, before: game.history, count: (game.history?.count ?? 0) + 1 };
|
|
291
|
+
const recorded = { ...game, history, last: { seat, move } };
|
|
292
|
+
switch (move.kind) {
|
|
293
|
+
case "next":
|
|
294
|
+
return dealRound(recorded, game.round + 1);
|
|
295
|
+
case "draw": {
|
|
296
|
+
const [drawn, ...boneyard] = game.boneyard;
|
|
297
|
+
const hands = game.hands.map((hand, at) => (at === seat ? [...hand, drawn] : hand));
|
|
298
|
+
return { ...recorded, hands, boneyard, drew: true };
|
|
299
|
+
}
|
|
300
|
+
case "pass": {
|
|
301
|
+
const trains = game.trains.map((one, at) => (at === seat ? { ...one, open: true } : one));
|
|
302
|
+
const passes = game.passes + 1;
|
|
303
|
+
const passed = { ...recorded, trains, passes };
|
|
304
|
+
if (game.boneyard.length === 0 && passes >= game.players.length)
|
|
305
|
+
return endRound(passed, "blocked", null);
|
|
306
|
+
return nextSeat(passed);
|
|
307
|
+
}
|
|
308
|
+
case "play":
|
|
309
|
+
return lay(recorded, move.tile, move.train);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* A game played again from its table and its moves: what reading a kept game
|
|
314
|
+
* back does. Null if any move is one the rules would not have taken.
|
|
315
|
+
*/
|
|
316
|
+
export function replayTrain(set, players, seed, options, computers, moves) {
|
|
317
|
+
let game = startTrain(set, players, seed, options, computers);
|
|
318
|
+
for (const move of moves) {
|
|
319
|
+
if (game === null)
|
|
320
|
+
return null;
|
|
321
|
+
game = playTrain(game, move);
|
|
322
|
+
}
|
|
323
|
+
return game;
|
|
324
|
+
}
|
|
325
|
+
/** The same table again, the same seats and options, with a fresh shuffle. */
|
|
326
|
+
export function trainAgain(game, seed) {
|
|
327
|
+
return startTrain(game.set, game.players, seed, game.options, game.computers);
|
|
328
|
+
}
|
|
329
|
+
/** A seat's name as the table reads it: the one given, or "Computer 3" for a computer's seat left blank, "Player 3" for a person's. */
|
|
330
|
+
export function trainPlayerName(game, seat) {
|
|
331
|
+
const given = game.players[seat]?.trim() ?? "";
|
|
332
|
+
if (given !== "")
|
|
333
|
+
return given;
|
|
334
|
+
return `${game.computers[seat] ? "Computer" : "Player"} ${seat + 1}`;
|
|
335
|
+
}
|
|
336
|
+
/** The seats a person plays: a table with two or more of them passes the device, and covers each hand between turns. */
|
|
337
|
+
export function peopleAt(game) {
|
|
338
|
+
return game.computers.flatMap((computer, seat) => (computer ? [] : [seat]));
|
|
339
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mexican Train, as its rules module (`mexicanTrain.ts`) speaks of it.
|
|
3
|
+
*
|
|
4
|
+
* A TILE IS A NUMBER: `low * 16 + high`, its two ends with the smaller first,
|
|
5
|
+
* so the set's every tile is one small integer and a hand is a list of them.
|
|
6
|
+
* Sixteen, because the largest set offered is double-fifteen (`tileOf`).
|
|
7
|
+
*
|
|
8
|
+
* A TILE LAID IN A TRAIN IS A NUMBER TOO: `from * 16 + to`, the end that
|
|
9
|
+
* touches the train first. `to` is the end the next tile must match, so a
|
|
10
|
+
* train's open end is the last laid tile's `to`, or the round's engine double
|
|
11
|
+
* when nothing has been laid on it yet.
|
|
12
|
+
*
|
|
13
|
+
* THE TRAINS ARE NUMBERED BY SEAT: train `s` is seat `s`'s own, and train
|
|
14
|
+
* `players.length` is the Mexican Train, which belongs to nobody and is open
|
|
15
|
+
* to everybody.
|
|
16
|
+
*/
|
|
17
|
+
/** Who sits where: 0 is the first player, round the table in the order the set-up named them. */
|
|
18
|
+
export type TrainSeat = number;
|
|
19
|
+
/** A domino in a hand or the boneyard: `low * 16 + high`. */
|
|
20
|
+
export type Domino = number;
|
|
21
|
+
/** A domino laid in a train, turned so its `from` end touches the train: `from * 16 + to`. */
|
|
22
|
+
export type LaidDomino = number;
|
|
23
|
+
/**
|
|
24
|
+
* How a double is dealt with, the table's one house rule about them.
|
|
25
|
+
*
|
|
26
|
+
* - `one` (the default, as most published rules play it): a double must be
|
|
27
|
+
* covered before anything else is played anywhere, and whoever laid it lays
|
|
28
|
+
* again to cover it.
|
|
29
|
+
* - `chain`: after laying a double you may lay another double in the same
|
|
30
|
+
* turn, anywhere one fits, before covering; then every double left open is
|
|
31
|
+
* covered, the last laid first, before anything else is played.
|
|
32
|
+
*/
|
|
33
|
+
export type DoublesRule = "one" | "chain";
|
|
34
|
+
/**
|
|
35
|
+
* When the Mexican Train may be started.
|
|
36
|
+
*
|
|
37
|
+
* - `any` (the default): on any turn, by anybody, as most published rules say.
|
|
38
|
+
* - `ownFirst`: a player may lay on the Mexican Train only once their own
|
|
39
|
+
* train has been started, the common house rule that keeps a first turn
|
|
40
|
+
* about your own train.
|
|
41
|
+
*/
|
|
42
|
+
export type MexicanStart = "any" | "ownFirst";
|
|
43
|
+
/** How many rounds: all of them, one for every double from the set's highest down to double blank, or half as many. */
|
|
44
|
+
export type TrainLength = "full" | "short";
|
|
45
|
+
/** What the set-up chose, beyond the set and the players. */
|
|
46
|
+
export type TrainOptions = {
|
|
47
|
+
length: TrainLength;
|
|
48
|
+
doubles: DoublesRule;
|
|
49
|
+
mexican: MexicanStart;
|
|
50
|
+
};
|
|
51
|
+
/** A train on the table: the tiles laid on it in order, and whether its owner's marker says anybody may play on it. */
|
|
52
|
+
export type Train = {
|
|
53
|
+
laid: readonly LaidDomino[];
|
|
54
|
+
/** Open to everybody: the Mexican Train always, a player's own once they could not play. */
|
|
55
|
+
open: boolean;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* One move at the table.
|
|
59
|
+
*
|
|
60
|
+
* - `play`: lay a tile from your hand on a train.
|
|
61
|
+
* - `draw`: take one tile from the boneyard, when you have nothing to play.
|
|
62
|
+
* - `pass`: nothing to play and nothing to draw (or the tile drawn will not
|
|
63
|
+
* go): your train's marker goes on, and the turn passes.
|
|
64
|
+
* - `next`: a round is over and everybody has seen how it went; deal the next.
|
|
65
|
+
*/
|
|
66
|
+
export type TrainMove = {
|
|
67
|
+
kind: "play";
|
|
68
|
+
tile: Domino;
|
|
69
|
+
train: number;
|
|
70
|
+
} | {
|
|
71
|
+
kind: "draw";
|
|
72
|
+
} | {
|
|
73
|
+
kind: "pass";
|
|
74
|
+
} | {
|
|
75
|
+
kind: "next";
|
|
76
|
+
};
|
|
77
|
+
/** One link of a game's record: a move, and every move before it. */
|
|
78
|
+
export type TrainHistory = {
|
|
79
|
+
readonly move: TrainMove;
|
|
80
|
+
readonly before: TrainHistory | null;
|
|
81
|
+
/** How many moves the record holds, this one included. */
|
|
82
|
+
readonly count: number;
|
|
83
|
+
};
|
|
84
|
+
export type TrainPhase = "playing" | "roundOver" | "finished";
|
|
85
|
+
/** Why a round ended: somebody played their last tile, or nobody could play and nothing was left to draw. */
|
|
86
|
+
export type RoundEnding = "domino" | "blocked";
|
|
87
|
+
/** A round's result, kept for the table of scores. */
|
|
88
|
+
export type RoundResult = {
|
|
89
|
+
/** The round's engine double, by its number: 12 for double-twelve. */
|
|
90
|
+
engine: number;
|
|
91
|
+
/** Pips left in each seat's hand when it ended: that round's score. */
|
|
92
|
+
pips: readonly number[];
|
|
93
|
+
ending: RoundEnding;
|
|
94
|
+
/** The seat that played out, on a round that ended that way. */
|
|
95
|
+
out: TrainSeat | null;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* A game, as its table and moves make it. Only the set, the options, the
|
|
99
|
+
* seed, the seats and the moves are ever kept (`encodeTrain`); everything
|
|
100
|
+
* else is read again from them (`replayTrain`), so a kept game can never hold
|
|
101
|
+
* a hand or a train its moves do not make, and a reload cannot deal again.
|
|
102
|
+
*/
|
|
103
|
+
export type TrainGame = {
|
|
104
|
+
/** The set, by its highest double: 9, 12 or 15. The party game's "board size". */
|
|
105
|
+
set: number;
|
|
106
|
+
options: TrainOptions;
|
|
107
|
+
/** What every shuffle of this game is drawn from. */
|
|
108
|
+
seed: number;
|
|
109
|
+
/** The names given at the table, in seat order: "" for one left blank. */
|
|
110
|
+
players: readonly string[];
|
|
111
|
+
/** Which seats a computer plays. */
|
|
112
|
+
computers: readonly boolean[];
|
|
113
|
+
/**
|
|
114
|
+
* Every move made, the last first, each pointing at the record before it:
|
|
115
|
+
* the game's whole record (`movesOf` reads it out in order). A chain rather
|
|
116
|
+
* than a list, so a move adds one link and copies nothing — a double-fifteen
|
|
117
|
+
* game runs to thousands of moves, and a list copied on every one of them
|
|
118
|
+
* is millions of copies a game.
|
|
119
|
+
*/
|
|
120
|
+
history: TrainHistory | null;
|
|
121
|
+
/** How many rounds this game plays, and which one is being played (0 for the first). */
|
|
122
|
+
rounds: number;
|
|
123
|
+
round: number;
|
|
124
|
+
/** The engine double's number this round: the set's highest in the first, one fewer each round after. */
|
|
125
|
+
engine: number;
|
|
126
|
+
hands: readonly (readonly Domino[])[];
|
|
127
|
+
/** Every seat's train, then the Mexican Train last. */
|
|
128
|
+
trains: readonly Train[];
|
|
129
|
+
/** The tiles still face down, in the order they will be drawn. */
|
|
130
|
+
boneyard: readonly Domino[];
|
|
131
|
+
toPlay: TrainSeat;
|
|
132
|
+
/**
|
|
133
|
+
* Trains whose last tile is a double not yet covered, the last laid last:
|
|
134
|
+
* while any is open, the only play anywhere is to cover the last of them.
|
|
135
|
+
*/
|
|
136
|
+
uncovered: readonly number[];
|
|
137
|
+
/** The player to move laid a double this turn and may still lay another before covering (`chain` only). */
|
|
138
|
+
chaining: boolean;
|
|
139
|
+
/** The player to move has drawn this turn: the tile drawn must be played if it can, or they pass. */
|
|
140
|
+
drew: boolean;
|
|
141
|
+
/** Turns passed in a row with nothing played: a whole table of them with the boneyard empty ends the round. */
|
|
142
|
+
passes: number;
|
|
143
|
+
/** Counts every change of the player to move: a new number is a new turn, and a covered hand to pass on. */
|
|
144
|
+
turn: number;
|
|
145
|
+
phase: TrainPhase;
|
|
146
|
+
results: readonly RoundResult[];
|
|
147
|
+
/** On a finished game, every seat with the lowest total. */
|
|
148
|
+
winners: readonly TrainSeat[];
|
|
149
|
+
/** The last move played, for the line that says what just happened. */
|
|
150
|
+
last: {
|
|
151
|
+
seat: TrainSeat;
|
|
152
|
+
move: TrainMove;
|
|
153
|
+
} | null;
|
|
154
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mexican Train, as its rules module (`mexicanTrain.ts`) speaks of it.
|
|
3
|
+
*
|
|
4
|
+
* A TILE IS A NUMBER: `low * 16 + high`, its two ends with the smaller first,
|
|
5
|
+
* so the set's every tile is one small integer and a hand is a list of them.
|
|
6
|
+
* Sixteen, because the largest set offered is double-fifteen (`tileOf`).
|
|
7
|
+
*
|
|
8
|
+
* A TILE LAID IN A TRAIN IS A NUMBER TOO: `from * 16 + to`, the end that
|
|
9
|
+
* touches the train first. `to` is the end the next tile must match, so a
|
|
10
|
+
* train's open end is the last laid tile's `to`, or the round's engine double
|
|
11
|
+
* when nothing has been laid on it yet.
|
|
12
|
+
*
|
|
13
|
+
* THE TRAINS ARE NUMBERED BY SEAT: train `s` is seat `s`'s own, and train
|
|
14
|
+
* `players.length` is the Mexican Train, which belongs to nobody and is open
|
|
15
|
+
* to everybody.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TrainGame } from "./mexicanTrain.types.ts";
|
|
2
|
+
export declare function encodeTrain(game: TrainGame): string;
|
|
3
|
+
/** A kept game read back, or null for nothing kept or anything these rules cannot play out again. */
|
|
4
|
+
export declare function decodeTrain(text: string | null): TrainGame | null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Relative, like the rest of lib/party: the browser specs import this, and Playwright resolves no alias.
|
|
2
|
+
import { TRAIN_DOUBLES, TRAIN_LENGTHS, TRAIN_MEXICAN } from "./mexicanTrain.constants.js";
|
|
3
|
+
import { movesOf, replayTrain } from "./mexicanTrain.js";
|
|
4
|
+
/**
|
|
5
|
+
* A GAME OF MEXICAN TRAIN AS TEXT, and back: what the table keeps in this
|
|
6
|
+
* browser after every move. Only the table and the moves are written — never
|
|
7
|
+
* a hand, a train or the boneyard, which the moves make again from the seed
|
|
8
|
+
* (`replayTrain`) — so what is read back is exactly the game those moves
|
|
9
|
+
* make, or nothing at all.
|
|
10
|
+
*
|
|
11
|
+
* A move is a few characters: `p<tile>.<train>` for a tile laid, `d` for a
|
|
12
|
+
* draw, `x` for a pass, `n` for the next round dealt.
|
|
13
|
+
*/
|
|
14
|
+
/** The version this text is written in: a kept game of any other is not read. */
|
|
15
|
+
const VERSION = 1;
|
|
16
|
+
function moveText(move) {
|
|
17
|
+
switch (move.kind) {
|
|
18
|
+
case "play":
|
|
19
|
+
return `p${move.tile}.${move.train}`;
|
|
20
|
+
case "draw":
|
|
21
|
+
return "d";
|
|
22
|
+
case "pass":
|
|
23
|
+
return "x";
|
|
24
|
+
case "next":
|
|
25
|
+
return "n";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function moveOf(text) {
|
|
29
|
+
if (text === "d")
|
|
30
|
+
return { kind: "draw" };
|
|
31
|
+
if (text === "x")
|
|
32
|
+
return { kind: "pass" };
|
|
33
|
+
if (text === "n")
|
|
34
|
+
return { kind: "next" };
|
|
35
|
+
const played = /^p(\d+)\.(\d+)$/.exec(text);
|
|
36
|
+
return played === null ? null : { kind: "play", tile: Number(played[1]), train: Number(played[2]) };
|
|
37
|
+
}
|
|
38
|
+
export function encodeTrain(game) {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
v: VERSION,
|
|
41
|
+
set: game.set,
|
|
42
|
+
options: game.options,
|
|
43
|
+
seed: game.seed,
|
|
44
|
+
players: game.players,
|
|
45
|
+
computers: game.computers,
|
|
46
|
+
moves: movesOf(game).map(moveText).join(","),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const isString = (value) => typeof value === "string";
|
|
50
|
+
function optionsOf(value) {
|
|
51
|
+
if (typeof value !== "object" || value === null)
|
|
52
|
+
return null;
|
|
53
|
+
const { length, doubles, mexican } = value;
|
|
54
|
+
if (!Object.values(TRAIN_LENGTHS).includes(length))
|
|
55
|
+
return null;
|
|
56
|
+
if (!Object.values(TRAIN_DOUBLES).includes(doubles))
|
|
57
|
+
return null;
|
|
58
|
+
if (!Object.values(TRAIN_MEXICAN).includes(mexican))
|
|
59
|
+
return null;
|
|
60
|
+
return { length, doubles, mexican };
|
|
61
|
+
}
|
|
62
|
+
/** A kept game read back, or null for nothing kept or anything these rules cannot play out again. */
|
|
63
|
+
export function decodeTrain(text) {
|
|
64
|
+
if (text === null)
|
|
65
|
+
return null;
|
|
66
|
+
let kept;
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(text);
|
|
69
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
70
|
+
return null;
|
|
71
|
+
kept = parsed;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const { v, set, seed, players, computers, moves } = kept;
|
|
77
|
+
const options = optionsOf(kept.options);
|
|
78
|
+
if (v !== VERSION || typeof set !== "number" || typeof seed !== "number" || options === null)
|
|
79
|
+
return null;
|
|
80
|
+
if (!Array.isArray(players) || !players.every(isString))
|
|
81
|
+
return null;
|
|
82
|
+
if (!Array.isArray(computers) || !computers.every((one) => typeof one === "boolean"))
|
|
83
|
+
return null;
|
|
84
|
+
if (!isString(moves))
|
|
85
|
+
return null;
|
|
86
|
+
const parsedMoves = moves === "" ? [] : moves.split(",").map(moveOf);
|
|
87
|
+
if (parsedMoves.some((move) => move === null))
|
|
88
|
+
return null;
|
|
89
|
+
return replayTrain(set, players, seed, options, computers, parsedMoves);
|
|
90
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Domino, TrainGame, TrainMove } from "./mexicanTrain.types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The longest run of tiles from `hand` that can be laid one after another
|
|
4
|
+
* against `end`, in the order they would be laid. A depth-first search over
|
|
5
|
+
* the hand, bounded by `PLAN_STEPS`, preferring the heavier run of two the
|
|
6
|
+
* same length: pips laid are pips that do not count.
|
|
7
|
+
*/
|
|
8
|
+
export declare function longestRun(hand: readonly Domino[], end: number): Domino[];
|
|
9
|
+
/** The move the computer in the seat to move makes now: the best-scored lay, or the draw, pass or next round it must take. */
|
|
10
|
+
export declare function computerMove(game: TrainGame): TrainMove;
|