@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.
- package/LICENSE +21 -0
- package/dist/backgammon.d.ts +146 -0
- package/dist/backgammon.js +1221 -0
- package/dist/backgammon.js.map +1 -0
- package/dist/blackjack.d.ts +66 -0
- package/dist/blackjack.js +452 -0
- package/dist/blackjack.js.map +1 -0
- package/dist/bluff.d.ts +60 -0
- package/dist/bluff.js +239 -0
- package/dist/bluff.js.map +1 -0
- package/dist/board.d.ts +387 -0
- package/dist/board.js +105 -0
- package/dist/board.js.map +1 -0
- package/dist/bridge.d.ts +86 -0
- package/dist/bridge.js +494 -0
- package/dist/bridge.js.map +1 -0
- package/dist/canasta.d.ts +88 -0
- package/dist/canasta.js +646 -0
- package/dist/canasta.js.map +1 -0
- package/dist/checkers.d.ts +108 -0
- package/dist/checkers.js +756 -0
- package/dist/checkers.js.map +1 -0
- package/dist/chess.d.ts +162 -0
- package/dist/chess.js +904 -0
- package/dist/chess.js.map +1 -0
- package/dist/chesscore.d.ts +136 -0
- package/dist/chesscore.js +1121 -0
- package/dist/chesscore.js.map +1 -0
- package/dist/combat.d.ts +102 -0
- package/dist/combat.js +772 -0
- package/dist/combat.js.map +1 -0
- package/dist/contractrummy.d.ts +77 -0
- package/dist/contractrummy.js +430 -0
- package/dist/contractrummy.js.map +1 -0
- package/dist/crazy8.d.ts +65 -0
- package/dist/crazy8.js +366 -0
- package/dist/crazy8.js.map +1 -0
- package/dist/cribbage.d.ts +75 -0
- package/dist/cribbage.js +428 -0
- package/dist/cribbage.js.map +1 -0
- package/dist/cuckoo.d.ts +43 -0
- package/dist/cuckoo.js +216 -0
- package/dist/cuckoo.js.map +1 -0
- package/dist/drinkingpyramid.d.ts +54 -0
- package/dist/drinkingpyramid.js +213 -0
- package/dist/drinkingpyramid.js.map +1 -0
- package/dist/drinks.d.ts +25 -0
- package/dist/drinks.js +40 -0
- package/dist/drinks.js.map +1 -0
- package/dist/dropfour.d.ts +103 -0
- package/dist/dropfour.js +577 -0
- package/dist/dropfour.js.map +1 -0
- package/dist/durak.d.ts +58 -0
- package/dist/durak.js +332 -0
- package/dist/durak.js.map +1 -0
- package/dist/euchre.d.ts +80 -0
- package/dist/euchre.js +453 -0
- package/dist/euchre.js.map +1 -0
- package/dist/fivedraw.d.ts +50 -0
- package/dist/fivedraw.js +289 -0
- package/dist/fivedraw.js.map +1 -0
- package/dist/fivehundred.d.ts +90 -0
- package/dist/fivehundred.js +504 -0
- package/dist/fivehundred.js.map +1 -0
- package/dist/freecell.d.ts +50 -0
- package/dist/freecell.js +327 -0
- package/dist/freecell.js.map +1 -0
- package/dist/fruitsalad.d.ts +69 -0
- package/dist/fruitsalad.js +427 -0
- package/dist/fruitsalad.js.map +1 -0
- package/dist/gin.d.ts +72 -0
- package/dist/gin.js +393 -0
- package/dist/gin.js.map +1 -0
- package/dist/go.d.ts +132 -0
- package/dist/go.js +659 -0
- package/dist/go.js.map +1 -0
- package/dist/goboard.d.ts +218 -0
- package/dist/goboard.js +880 -0
- package/dist/goboard.js.map +1 -0
- package/dist/gofish.d.ts +37 -0
- package/dist/gofish.js +235 -0
- package/dist/gofish.js.map +1 -0
- package/dist/golf.d.ts +64 -0
- package/dist/golf.js +343 -0
- package/dist/golf.js.map +1 -0
- package/dist/handfoot.d.ts +86 -0
- package/dist/handfoot.js +515 -0
- package/dist/handfoot.js.map +1 -0
- package/dist/hearts.d.ts +65 -0
- package/dist/hearts.js +390 -0
- package/dist/hearts.js.map +1 -0
- package/dist/holdem.d.ts +45 -0
- package/dist/holdem.js +286 -0
- package/dist/holdem.js.map +1 -0
- package/dist/horserace.d.ts +49 -0
- package/dist/horserace.js +207 -0
- package/dist/horserace.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/kingscorner.d.ts +59 -0
- package/dist/kingscorner.js +374 -0
- package/dist/kingscorner.js.map +1 -0
- package/dist/kingscup.d.ts +44 -0
- package/dist/kingscup.js +185 -0
- package/dist/kingscup.js.map +1 -0
- package/dist/kit.d.ts +283 -0
- package/dist/kit.js +73 -0
- package/dist/kit.js.map +1 -0
- package/dist/klondike.d.ts +57 -0
- package/dist/klondike.js +314 -0
- package/dist/klondike.js.map +1 -0
- package/dist/liarspoker.d.ts +61 -0
- package/dist/liarspoker.js +298 -0
- package/dist/liarspoker.js.map +1 -0
- package/dist/memory.d.ts +36 -0
- package/dist/memory.js +181 -0
- package/dist/memory.js.map +1 -0
- package/dist/minigolf.d.ts +104 -0
- package/dist/minigolf.js +724 -0
- package/dist/minigolf.js.map +1 -0
- package/dist/minigolfcourse.d.ts +127 -0
- package/dist/minigolfcourse.js +470 -0
- package/dist/minigolfcourse.js.map +1 -0
- package/dist/offers.d.ts +136 -0
- package/dist/offers.js +279 -0
- package/dist/offers.js.map +1 -0
- package/dist/ohhell.d.ts +66 -0
- package/dist/ohhell.js +331 -0
- package/dist/ohhell.js.map +1 -0
- package/dist/oldmaid.d.ts +40 -0
- package/dist/oldmaid.js +186 -0
- package/dist/oldmaid.js.map +1 -0
- package/dist/pinochle.d.ts +94 -0
- package/dist/pinochle.js +466 -0
- package/dist/pinochle.js.map +1 -0
- package/dist/pitch.d.ts +77 -0
- package/dist/pitch.js +362 -0
- package/dist/pitch.js.map +1 -0
- package/dist/poker.d.ts +79 -0
- package/dist/poker.js +268 -0
- package/dist/poker.js.map +1 -0
- package/dist/president.d.ts +56 -0
- package/dist/president.js +339 -0
- package/dist/president.js.map +1 -0
- package/dist/pyramid.d.ts +39 -0
- package/dist/pyramid.js +200 -0
- package/dist/pyramid.js.map +1 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +70 -0
- package/dist/registry.js.map +1 -0
- package/dist/reversi.d.ts +69 -0
- package/dist/reversi.js +584 -0
- package/dist/reversi.js.map +1 -0
- package/dist/ridethebus.d.ts +48 -0
- package/dist/ridethebus.js +257 -0
- package/dist/ridethebus.js.map +1 -0
- package/dist/rummy500.d.ts +89 -0
- package/dist/rummy500.js +474 -0
- package/dist/rummy500.js.map +1 -0
- package/dist/sevens.d.ts +48 -0
- package/dist/sevens.js +221 -0
- package/dist/sevens.js.map +1 -0
- package/dist/slapjack.d.ts +42 -0
- package/dist/slapjack.js +224 -0
- package/dist/slapjack.js.map +1 -0
- package/dist/speed.d.ts +44 -0
- package/dist/speed.js +201 -0
- package/dist/speed.js.map +1 -0
- package/dist/spider.d.ts +51 -0
- package/dist/spider.js +320 -0
- package/dist/spider.js.map +1 -0
- package/dist/spite.d.ts +75 -0
- package/dist/spite.js +372 -0
- package/dist/spite.js.map +1 -0
- package/dist/sport.d.ts +266 -0
- package/dist/sport.js +48 -0
- package/dist/sport.js.map +1 -0
- package/dist/switch.d.ts +69 -0
- package/dist/switch.js +393 -0
- package/dist/switch.js.map +1 -0
- package/dist/table.d.ts +284 -0
- package/dist/table.js +67 -0
- package/dist/table.js.map +1 -0
- package/dist/talk.d.ts +17 -0
- package/dist/talk.js +44 -0
- package/dist/talk.js.map +1 -0
- package/dist/thirtyone.d.ts +66 -0
- package/dist/thirtyone.js +372 -0
- package/dist/thirtyone.js.map +1 -0
- package/dist/tictactoe.d.ts +68 -0
- package/dist/tictactoe.js +541 -0
- package/dist/tictactoe.js.map +1 -0
- package/dist/tricks.d.ts +21 -0
- package/dist/tricks.js +48 -0
- package/dist/tricks.js.map +1 -0
- package/dist/war.d.ts +44 -0
- package/dist/war.js +185 -0
- package/dist/war.js.map +1 -0
- package/dist/whist.d.ts +52 -0
- package/dist/whist.js +218 -0
- package/dist/whist.js.map +1 -0
- package/dist/yacht.d.ts +96 -0
- package/dist/yacht.js +653 -0
- package/dist/yacht.js.map +1 -0
- package/package.json +41 -4
- package/README.md +0 -3
package/dist/yacht.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
// Yacht: five dice and a scorecard. On your turn you roll all five, keep back any you like, and roll
|
|
2
|
+
// the rest again, up to three rolls in all; then the dice have to go in one box of your sheet that
|
|
3
|
+
// is still empty, for whatever they score there (nothing, if they don't fit). Every box is filled
|
|
4
|
+
// once, and when every sheet is full the highest total wins. One player can play alone for a score.
|
|
5
|
+
//
|
|
6
|
+
// There are three sheets. Classic is the thirteen boxes most families know. Yatzy is the
|
|
7
|
+
// Scandinavian sheet, fifteen boxes with pairs in it, where a box scores the dice that make it
|
|
8
|
+
// rather than a set number. Quick is the bottom half of the classic sheet on its own: seven rolls
|
|
9
|
+
// and you're done.
|
|
10
|
+
//
|
|
11
|
+
// A table rolls in turn, or everyone at once: then each player has dice of their own that nobody
|
|
12
|
+
// else sees, works down their sheet at their own pace, and the table moves to the next round when
|
|
13
|
+
// everyone has scored (`turn` is null, as when both players place their ships in Combat).
|
|
14
|
+
//
|
|
15
|
+
// With drinking mode on the table is called Shotzee: roll five of a kind and everyone else takes a
|
|
16
|
+
// shot; put a zero on your sheet and you drink.
|
|
17
|
+
//
|
|
18
|
+
// There is no board. The view is the board kit's `dice` (thrown here from the game's seeded random
|
|
19
|
+
// source, like a deal) and its score `sheet`, whose cells are how you score.
|
|
20
|
+
import { mulberry32 } from "@journey-to-code/rules";
|
|
21
|
+
import { drink, newTally } from "./drinks.js";
|
|
22
|
+
import { drinkingField } from "./kit.js";
|
|
23
|
+
import { careless, clone, GameError, nextRandom, rankSeats, who } from "./table.js";
|
|
24
|
+
export const classicYacht = { id: "yacht-classic", name: "Classic", sheet: "classic", bonus: true, order: "turns", drinking: false };
|
|
25
|
+
export const yatzyYacht = { id: "yacht-yatzy", name: "Yatzy", sheet: "yatzy", bonus: false, order: "turns", drinking: false };
|
|
26
|
+
export const quickYacht = { id: "yacht-quick", name: "Quick (seven boxes)", sheet: "quick", bonus: true, order: "turns", drinking: false };
|
|
27
|
+
export const shotzee = { ...classicYacht, id: "yacht-drinking", name: "Shotzee 🍺", drinking: true };
|
|
28
|
+
const PRESETS = [classicYacht, yatzyYacht, quickYacht, shotzee];
|
|
29
|
+
export const YACHT_DICE = 5;
|
|
30
|
+
export const YACHT_ROLLS = 3;
|
|
31
|
+
/** What an extra Yacht is worth, when the Yacht box holds 50. */
|
|
32
|
+
export const YACHT_BONUS = 100;
|
|
33
|
+
const FACE_NAMES = ["", "Ones", "Twos", "Threes", "Fours", "Fives", "Sixes"];
|
|
34
|
+
const FACE_WORDS = ["", "one", "two", "three", "four", "five", "six"];
|
|
35
|
+
const countsOf = (dice) => {
|
|
36
|
+
const counts = [0, 0, 0, 0, 0, 0, 0];
|
|
37
|
+
for (const d of dice)
|
|
38
|
+
counts[d] += 1;
|
|
39
|
+
return counts;
|
|
40
|
+
};
|
|
41
|
+
const sumOf = (counts) => counts.reduce((n, c, face) => n + c * face, 0);
|
|
42
|
+
/** The highest face that at least `n` dice show, or 0. */
|
|
43
|
+
const kind = (counts, n) => {
|
|
44
|
+
for (let face = 6; face >= 1; face--)
|
|
45
|
+
if (counts[face] >= n)
|
|
46
|
+
return face;
|
|
47
|
+
return 0;
|
|
48
|
+
};
|
|
49
|
+
/** Whether the dice hold `length` faces in a row starting anywhere from `from` to `to`. */
|
|
50
|
+
const run = (counts, length, from = 1, to = 7 - length) => {
|
|
51
|
+
for (let start = from; start <= to; start++) {
|
|
52
|
+
let all = true;
|
|
53
|
+
for (let face = start; face < start + length; face++)
|
|
54
|
+
if (!counts[face])
|
|
55
|
+
all = false;
|
|
56
|
+
if (all)
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
};
|
|
61
|
+
/** Three of one face and two of another. */
|
|
62
|
+
const house = (counts) => counts.includes(3) && counts.includes(2);
|
|
63
|
+
const upper = (pars) => [1, 2, 3, 4, 5, 6].map((face) => ({ id: FACE_NAMES[face].toLowerCase(), label: FACE_NAMES[face], sub: `Add the ${FACE_NAMES[face].toLowerCase()}`, face, score: (c) => c[face] * face, par: pars[face - 1] }));
|
|
64
|
+
// The pars began as what the computer player itself averaged in each box over a few thousand games
|
|
65
|
+
// alone, and were then nudged to whatever played best (Yatzy's are a little under its averages).
|
|
66
|
+
const CLASSIC_LOWER = [
|
|
67
|
+
{ id: "threeKind", label: "Three of a kind", sub: "Add all the dice", score: (c, sum) => (kind(c, 3) ? sum : 0), par: 23 },
|
|
68
|
+
{ id: "fourKind", label: "Four of a kind", sub: "Add all the dice", score: (c, sum) => (kind(c, 4) ? sum : 0), par: 17 },
|
|
69
|
+
{ id: "fullHouse", label: "Full house", sub: "Three and two: 25", score: (c) => (house(c) ? 25 : 0), joker: 25, par: 20 },
|
|
70
|
+
{ id: "smallStraight", label: "Small straight", sub: "Four in a row: 30", score: (c) => (run(c, 4) ? 30 : 0), joker: 30, par: 29 },
|
|
71
|
+
{ id: "largeStraight", label: "Large straight", sub: "Five in a row: 40", score: (c) => (run(c, 5) ? 40 : 0), joker: 40, par: 31.5 },
|
|
72
|
+
{ id: "yacht", label: "Yacht", sub: "Five of a kind: 50", score: (c) => (kind(c, 5) ? 50 : 0), par: 17.5 },
|
|
73
|
+
{ id: "chance", label: "Chance", sub: "Add all the dice", score: (_, sum) => sum, par: 23 },
|
|
74
|
+
];
|
|
75
|
+
const YATZY_LOWER = [
|
|
76
|
+
{ id: "pair", label: "One pair", sub: "Add the pair", score: (c) => kind(c, 2) * 2, par: 9 },
|
|
77
|
+
{
|
|
78
|
+
id: "twoPairs",
|
|
79
|
+
label: "Two pairs",
|
|
80
|
+
sub: "Add both pairs",
|
|
81
|
+
score: (c) => {
|
|
82
|
+
const high = kind(c, 2);
|
|
83
|
+
// Two different pairs: four of a kind isn't two.
|
|
84
|
+
for (let face = high - 1; face >= 1; face--)
|
|
85
|
+
if (c[face] >= 2)
|
|
86
|
+
return (high + face) * 2;
|
|
87
|
+
return 0;
|
|
88
|
+
},
|
|
89
|
+
par: 15.5,
|
|
90
|
+
},
|
|
91
|
+
{ id: "threeKind", label: "Three of a kind", sub: "Add the three", score: (c) => kind(c, 3) * 3, par: 13 },
|
|
92
|
+
{ id: "fourKind", label: "Four of a kind", sub: "Add the four", score: (c) => kind(c, 4) * 4, par: 12.5 },
|
|
93
|
+
{ id: "smallStraight", label: "Small straight", sub: "1 to 5: 15", score: (c) => (run(c, 5, 1, 1) ? 15 : 0), par: 3.5 },
|
|
94
|
+
{ id: "largeStraight", label: "Large straight", sub: "2 to 6: 20", score: (c) => (run(c, 5, 2, 2) ? 20 : 0), par: 8.5 },
|
|
95
|
+
{ id: "fullHouse", label: "Full house", sub: "Add all the dice", score: (c, sum) => (house(c) ? sum : 0), par: 17.5 },
|
|
96
|
+
{ id: "chance", label: "Chance", sub: "Add all the dice", score: (_, sum) => sum, par: 18.5 },
|
|
97
|
+
{ id: "yacht", label: "Yatzy", sub: "Five of a kind: 50", score: (c) => (kind(c, 5) ? 50 : 0), par: 17.5 },
|
|
98
|
+
];
|
|
99
|
+
const SHEETS = {
|
|
100
|
+
classic: { boxes: [...upper([2, 5.3, 8.4, 11.8, 14.5, 17]), ...CLASSIC_LOWER], bonus: 35, bonusAt: 63 },
|
|
101
|
+
yatzy: { boxes: [...upper([1.6, 4.6, 7, 9.6, 12.2, 14.6]), ...YATZY_LOWER], bonus: 50, bonusAt: 63 },
|
|
102
|
+
// Seven turns go less far than thirteen: the same boxes fetch less.
|
|
103
|
+
quick: { boxes: CLASSIC_LOWER.map((b, i) => ({ ...b, par: [22, 12, 16, 28, 28, 8, 22][i] })), bonus: 0, bonusAt: 0 },
|
|
104
|
+
};
|
|
105
|
+
/** The boxes of a sheet, in the order they are written down. */
|
|
106
|
+
export const yachtBoxes = (rules) => SHEETS[rules.sheet].boxes;
|
|
107
|
+
/** What five dice score in a box, by the plain rules of the sheet (no joker). */
|
|
108
|
+
export function yachtScore(rules, box, dice) {
|
|
109
|
+
const found = SHEETS[rules.sheet].boxes.find((b) => b.id === box);
|
|
110
|
+
if (!found)
|
|
111
|
+
throw new Error(`No such box: ${box}`);
|
|
112
|
+
const counts = countsOf(dice);
|
|
113
|
+
return found.score(counts, sumOf(counts));
|
|
114
|
+
}
|
|
115
|
+
const together = (s) => s.rules.order === "together";
|
|
116
|
+
const handOf = (s, seat) => s.hands[together(s) ? seat : 0];
|
|
117
|
+
const sheetOf = (s) => SHEETS[s.rules.sheet];
|
|
118
|
+
const newHand = () => ({ dice: Array(YACHT_DICE).fill(0), held: Array(YACHT_DICE).fill(false), rolls: 0, throws: 0, done: false });
|
|
119
|
+
/** Whether it is this seat's go: its turn, or (everyone at once) it hasn't scored this round. */
|
|
120
|
+
const canPlay = (s, seat) => s.phase === "playing" && (together(s) ? !handOf(s, seat).done : s.turn === seat);
|
|
121
|
+
/** What the five-of-a-kind box is called at this table. */
|
|
122
|
+
const yachtName = (rules) => (rules.drinking ? "Shotzee" : rules.sheet === "yatzy" ? "Yatzy" : "Yacht");
|
|
123
|
+
const boxLabel = (rules, box) => (box.id === "yacht" && rules.drinking ? "Shotzee!" : box.label);
|
|
124
|
+
export function newYacht(rules, seats, seed) {
|
|
125
|
+
const s = { game: "yacht", rules, seats, rng: seed >>> 0, round: 1, last: null, phase: "playing", narration: null };
|
|
126
|
+
s.first = Math.floor(nextRandom(s)() * seats);
|
|
127
|
+
s.turn = s.first;
|
|
128
|
+
s.hands = Array.from({ length: rules.order === "together" ? seats : 1 }, newHand);
|
|
129
|
+
s.sheets = Array.from({ length: seats }, () => Array(SHEETS[rules.sheet].boxes.length).fill(null));
|
|
130
|
+
s.extra = Array(seats).fill(0);
|
|
131
|
+
s.tally = newTally(seats);
|
|
132
|
+
return s;
|
|
133
|
+
}
|
|
134
|
+
// ---- Scoring ------------------------------------------------------------------------------------
|
|
135
|
+
/** The top half of a seat's sheet added up, and the bonus it has earned (0 until it has). */
|
|
136
|
+
function upperOf(s, seat) {
|
|
137
|
+
const { boxes, bonus, bonusAt } = sheetOf(s);
|
|
138
|
+
let sum = 0;
|
|
139
|
+
let open = false;
|
|
140
|
+
boxes.forEach((b, i) => {
|
|
141
|
+
if (b.face === undefined)
|
|
142
|
+
return;
|
|
143
|
+
if (s.sheets[seat][i] === null)
|
|
144
|
+
open = true;
|
|
145
|
+
else
|
|
146
|
+
sum += s.sheets[seat][i];
|
|
147
|
+
});
|
|
148
|
+
return { sum, bonus: bonus && sum >= bonusAt ? bonus : 0, open };
|
|
149
|
+
}
|
|
150
|
+
/** A seat's total: every box, the top half's bonus, and the extra Yachts. */
|
|
151
|
+
export function yachtTotal(s, seat) {
|
|
152
|
+
return s.sheets[seat].reduce((n, v) => n + (v ?? 0), 0) + upperOf(s, seat).bonus + s.extra[seat] * YACHT_BONUS;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The face of a joker: five of a kind thrown when the Yacht box is already filled (with 50 or
|
|
156
|
+
* with nothing), in rules that play extra Yachts. 0 when the dice aren't one.
|
|
157
|
+
*/
|
|
158
|
+
function jokerOf(s, seat, counts) {
|
|
159
|
+
if (!s.rules.bonus || s.rules.sheet === "yatzy")
|
|
160
|
+
return 0;
|
|
161
|
+
const face = kind(counts, 5);
|
|
162
|
+
const yacht = sheetOf(s).boxes.findIndex((b) => b.id === "yacht");
|
|
163
|
+
return face && s.sheets[seat][yacht] !== null ? face : 0;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The boxes a seat may put these dice in, and what each would score. Usually that is every empty
|
|
167
|
+
* box. A joker is told where to go: its own number in the top half if that is empty; failing
|
|
168
|
+
* that any empty box of the bottom half, where it counts as whatever the box wants (a full house,
|
|
169
|
+
* either straight); failing that, any box that's left.
|
|
170
|
+
*/
|
|
171
|
+
function optionsFor(s, seat, counts) {
|
|
172
|
+
const { boxes } = sheetOf(s);
|
|
173
|
+
const sum = sumOf(counts);
|
|
174
|
+
const open = boxes.map((_, i) => i).filter((i) => s.sheets[seat][i] === null);
|
|
175
|
+
const plain = (list) => list.map((box) => ({ box, score: boxes[box].score(counts, sum) }));
|
|
176
|
+
const joker = jokerOf(s, seat, counts);
|
|
177
|
+
if (!joker)
|
|
178
|
+
return plain(open);
|
|
179
|
+
const own = open.find((i) => boxes[i].face === joker);
|
|
180
|
+
if (own !== undefined)
|
|
181
|
+
return plain([own]);
|
|
182
|
+
const lower = open.filter((i) => boxes[i].face === undefined);
|
|
183
|
+
if (lower.length)
|
|
184
|
+
return lower.map((box) => ({ box, score: boxes[box].joker ?? boxes[box].score(counts, sum) }));
|
|
185
|
+
return plain(open);
|
|
186
|
+
}
|
|
187
|
+
/** The boxes a seat can score in now, by id, with what each would give. Empty until it has rolled. */
|
|
188
|
+
export function yachtOptions(s, seat) {
|
|
189
|
+
const hand = handOf(s, seat);
|
|
190
|
+
if (!canPlay(s, seat) || hand.rolls === 0)
|
|
191
|
+
return [];
|
|
192
|
+
return optionsFor(s, seat, countsOf(hand.dice)).map((o) => ({ box: sheetOf(s).boxes[o.box].id, score: o.score }));
|
|
193
|
+
}
|
|
194
|
+
// ---- Moves --------------------------------------------------------------------------------------
|
|
195
|
+
/** After a seat has scored: the next player's turn, the next round, or the end. */
|
|
196
|
+
function moveOn(s, seat) {
|
|
197
|
+
const rounds = sheetOf(s).boxes.length;
|
|
198
|
+
if (together(s)) {
|
|
199
|
+
if (!s.hands.every((h) => h.done))
|
|
200
|
+
return;
|
|
201
|
+
for (const h of s.hands)
|
|
202
|
+
h.done = false;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
s.turn = (seat + 1) % s.seats;
|
|
206
|
+
if (s.turn !== s.first)
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (s.round >= rounds)
|
|
210
|
+
s.phase = "finished";
|
|
211
|
+
else
|
|
212
|
+
s.round += 1;
|
|
213
|
+
}
|
|
214
|
+
function apply(state, seat, move) {
|
|
215
|
+
if (move?.type !== "roll" && move?.type !== "hold" && move?.type !== "score")
|
|
216
|
+
throw new GameError("That's not a move in Yacht.");
|
|
217
|
+
if (state.phase !== "playing")
|
|
218
|
+
throw new GameError("The game is over.");
|
|
219
|
+
if (!Number.isInteger(seat) || seat < 0 || seat >= state.seats)
|
|
220
|
+
throw new GameError("You're not at this table.");
|
|
221
|
+
if (!canPlay(state, seat))
|
|
222
|
+
throw new GameError(together(state) ? "You've scored this round: waiting for the others." : "It's not your turn.");
|
|
223
|
+
const { rules } = state;
|
|
224
|
+
const before = handOf(state, seat);
|
|
225
|
+
if (move.type === "roll") {
|
|
226
|
+
if (before.rolls >= YACHT_ROLLS)
|
|
227
|
+
throw new GameError("That was your last roll: pick a box to score.");
|
|
228
|
+
if (before.rolls > 0 && before.held.every(Boolean))
|
|
229
|
+
throw new GameError("Let go of a die to roll again.");
|
|
230
|
+
const s = clone(state);
|
|
231
|
+
const hand = handOf(s, seat);
|
|
232
|
+
const rand = nextRandom(s);
|
|
233
|
+
// The first roll of a turn is all five: nothing is kept from the turn before.
|
|
234
|
+
if (hand.rolls === 0)
|
|
235
|
+
hand.held.fill(false);
|
|
236
|
+
hand.dice = hand.dice.map((value, i) => (hand.held[i] ? value : 1 + Math.floor(rand() * 6)));
|
|
237
|
+
hand.rolls += 1;
|
|
238
|
+
hand.throws += 1;
|
|
239
|
+
const five = kind(countsOf(hand.dice), 5) > 0;
|
|
240
|
+
// Everyone at once, a roll is nobody else's business, unless it's five of a kind: that gets shouted.
|
|
241
|
+
if (five) {
|
|
242
|
+
const others = Array.from({ length: s.seats }, (_, i) => i).filter((i) => i !== seat);
|
|
243
|
+
const shots = rules.drinking && others.length ? ` · ${drink(s.tally, others, 1)}` : "";
|
|
244
|
+
s.narration = `${who(seat)} rolls a ${yachtName(rules)}!${shots}`;
|
|
245
|
+
}
|
|
246
|
+
else if (!together(s))
|
|
247
|
+
s.narration = `${who(seat)} ${hand.rolls === 1 ? "rolls" : hand.rolls === YACHT_ROLLS ? "rolls for the last time" : "rolls again"}`;
|
|
248
|
+
return s;
|
|
249
|
+
}
|
|
250
|
+
if (move.type === "hold") {
|
|
251
|
+
if (before.rolls === 0)
|
|
252
|
+
throw new GameError("Roll the dice first.");
|
|
253
|
+
if (before.rolls >= YACHT_ROLLS)
|
|
254
|
+
throw new GameError("That was your last roll: pick a box to score.");
|
|
255
|
+
const die = Number(move.die);
|
|
256
|
+
if (!Number.isInteger(die) || die < 0 || die >= YACHT_DICE)
|
|
257
|
+
throw new GameError("That's not one of the dice.");
|
|
258
|
+
const s = clone(state);
|
|
259
|
+
const hand = handOf(s, seat);
|
|
260
|
+
hand.held[die] = !hand.held[die];
|
|
261
|
+
return s;
|
|
262
|
+
}
|
|
263
|
+
const { boxes } = sheetOf(state);
|
|
264
|
+
const box = boxes.findIndex((b) => b.id === move.cell);
|
|
265
|
+
if (box < 0)
|
|
266
|
+
throw new GameError("That's not a box on the sheet.");
|
|
267
|
+
if (before.rolls === 0)
|
|
268
|
+
throw new GameError("Roll the dice first.");
|
|
269
|
+
if (state.sheets[seat][box] !== null)
|
|
270
|
+
throw new GameError("You've filled that box in already.");
|
|
271
|
+
const counts = countsOf(before.dice);
|
|
272
|
+
const option = optionsFor(state, seat, counts).find((o) => o.box === box);
|
|
273
|
+
if (!option) {
|
|
274
|
+
const face = jokerOf(state, seat, counts);
|
|
275
|
+
const own = boxes.some((b, i) => b.face === face && state.sheets[seat][i] === null);
|
|
276
|
+
throw new GameError(own ? `An extra ${yachtName(rules)} has to go in your ${FACE_NAMES[face]}.` : `An extra ${yachtName(rules)} goes in the bottom half while there's room there.`);
|
|
277
|
+
}
|
|
278
|
+
const s = clone(state);
|
|
279
|
+
const hand = handOf(s, seat);
|
|
280
|
+
const label = boxLabel(rules, boxes[box]);
|
|
281
|
+
let line = option.score > 0 ? `${who(seat)} scores ${option.score} in ${label}` : `${who(seat)} scratches ${label}`;
|
|
282
|
+
// An extra Yacht pays its bonus only if the first one was scored: a scratched box earns nothing.
|
|
283
|
+
if (jokerOf(s, seat, counts) && s.sheets[seat][boxes.findIndex((b) => b.id === "yacht")] === 50) {
|
|
284
|
+
s.extra[seat] += 1;
|
|
285
|
+
line += `, and ${YACHT_BONUS} for another ${yachtName(rules)}`;
|
|
286
|
+
}
|
|
287
|
+
s.sheets[seat][box] = option.score;
|
|
288
|
+
if (rules.drinking && option.score === 0)
|
|
289
|
+
line += ` · ${drink(s.tally, [seat], 1)}`;
|
|
290
|
+
s.last = { seat, box };
|
|
291
|
+
s.narration = line;
|
|
292
|
+
// The dice stay as they were scored until the next roll picks them up.
|
|
293
|
+
hand.rolls = 0;
|
|
294
|
+
hand.held.fill(false);
|
|
295
|
+
hand.done = together(s);
|
|
296
|
+
moveOn(s, seat);
|
|
297
|
+
return s;
|
|
298
|
+
}
|
|
299
|
+
const totals = (s) => s.sheets.map((_, seat) => yachtTotal(s, seat));
|
|
300
|
+
const winnersOf = (s) => {
|
|
301
|
+
const all = totals(s);
|
|
302
|
+
const best = Math.max(...all);
|
|
303
|
+
return all.flatMap((t, seat) => (t === best ? [seat] : []));
|
|
304
|
+
};
|
|
305
|
+
// ---- What a seat sees ---------------------------------------------------------------------------
|
|
306
|
+
/** The sheet as a table of strings: a column per box, with the bonuses worked in where they belong. */
|
|
307
|
+
function sheetView(s, seat) {
|
|
308
|
+
const { rules } = s;
|
|
309
|
+
const { boxes, bonus, bonusAt } = sheetOf(s);
|
|
310
|
+
const jokers = rules.bonus && rules.sheet !== "yatzy";
|
|
311
|
+
// Where each box sits among the columns: the top half's bonus comes straight after it.
|
|
312
|
+
const lastUpper = boxes.reduce((at, b, i) => (b.face !== undefined ? i : at), -1);
|
|
313
|
+
const columnOf = (box) => (lastUpper >= 0 && box > lastUpper ? box + 1 : box);
|
|
314
|
+
const columns = [];
|
|
315
|
+
boxes.forEach((b, i) => {
|
|
316
|
+
columns.push({ id: b.id, label: boxLabel(rules, b), sub: b.sub });
|
|
317
|
+
if (i === lastUpper)
|
|
318
|
+
columns.push({ label: "Bonus", sub: `${bonus} for ${bonusAt} or more`, kind: "sum" });
|
|
319
|
+
});
|
|
320
|
+
if (jokers)
|
|
321
|
+
columns.push({ label: `${yachtName(rules)} bonus`, sub: `${YACHT_BONUS} for each extra one`, kind: "sum" });
|
|
322
|
+
const hand = handOf(s, seat);
|
|
323
|
+
const options = canPlay(s, seat) && hand.rolls > 0 ? optionsFor(s, seat, countsOf(hand.dice)) : [];
|
|
324
|
+
return {
|
|
325
|
+
title: rules.drinking ? "Shotzee" : "Yacht",
|
|
326
|
+
columns,
|
|
327
|
+
current: null,
|
|
328
|
+
rows: s.sheets.map((sheet, i) => {
|
|
329
|
+
const top = upperOf(s, i);
|
|
330
|
+
const cells = [];
|
|
331
|
+
sheet.forEach((v, box) => {
|
|
332
|
+
cells.push(v === null ? "" : String(v));
|
|
333
|
+
// Until the bonus is won or lost it shows how far along the top half is.
|
|
334
|
+
if (box === lastUpper)
|
|
335
|
+
cells.push(top.bonus ? String(top.bonus) : top.open ? `${top.sum}/${bonusAt}` : "0");
|
|
336
|
+
});
|
|
337
|
+
if (jokers)
|
|
338
|
+
cells.push(s.extra[i] ? String(s.extra[i] * YACHT_BONUS) : "");
|
|
339
|
+
return { seat: i, cells, total: String(yachtTotal(s, i)) };
|
|
340
|
+
}),
|
|
341
|
+
totalLabel: "Total",
|
|
342
|
+
last: s.last ? { seat: s.last.seat, column: columnOf(s.last.box) } : null,
|
|
343
|
+
pick: options.length ? { type: "score", cells: options.map((o) => ({ column: columnOf(o.box), would: String(o.score) })) } : null,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function resultOf(s) {
|
|
347
|
+
const { boxes, bonus } = sheetOf(s);
|
|
348
|
+
const seats = s.sheets.map((_, i) => i);
|
|
349
|
+
const half = (top) => seats.map((i) => String(boxes.reduce((n, b, box) => n + ((b.face !== undefined) === top ? (s.sheets[i][box] ?? 0) : 0), 0)));
|
|
350
|
+
const w = winnersOf(s);
|
|
351
|
+
return {
|
|
352
|
+
key: "final",
|
|
353
|
+
title: overTitle(s, w),
|
|
354
|
+
sides: seats.map((i) => [i]),
|
|
355
|
+
rows: [
|
|
356
|
+
...(bonus ? [{ label: "Top half", values: half(true) }, { label: "Bonus", values: seats.map((i) => String(upperOf(s, i).bonus)) }, { label: "Bottom half", values: half(false) }] : []),
|
|
357
|
+
...(s.extra.some(Boolean) ? [{ label: `${yachtName(s.rules)} bonus`, values: s.extra.map((n) => String(n * YACHT_BONUS)) }] : []),
|
|
358
|
+
{ label: "Total", values: totals(s).map(String), tone: "total" },
|
|
359
|
+
...(s.rules.drinking ? [{ label: "Drinks 🍺", values: s.tally.drank.map(String) }] : []),
|
|
360
|
+
],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function overTitle(s, w) {
|
|
364
|
+
const best = yachtTotal(s, w[0]);
|
|
365
|
+
if (s.seats === 1)
|
|
366
|
+
return `${who(0)} scores ${best}`;
|
|
367
|
+
if (w.length === s.seats)
|
|
368
|
+
return `Everyone ties on ${best}!`;
|
|
369
|
+
return w.length > 1 ? `${w.map(who).join(" and ")} tie on ${best}!` : `${who(w[0])} wins with ${best}!`;
|
|
370
|
+
}
|
|
371
|
+
function view(s, seat) {
|
|
372
|
+
const { rules } = s;
|
|
373
|
+
const over = s.phase === "finished";
|
|
374
|
+
const all = together(s);
|
|
375
|
+
const mine = canPlay(s, seat);
|
|
376
|
+
const hand = handOf(s, seat);
|
|
377
|
+
const rounds = sheetOf(s).boxes.length;
|
|
378
|
+
// Dice that have been scored lie where they fell, dimmed, until the next roll picks them up.
|
|
379
|
+
const spent = hand.rolls === 0;
|
|
380
|
+
const dice = {
|
|
381
|
+
dice: hand.dice.map((value, id) => ({ id, value: value || null, held: !spent && hand.held[id], used: spent && value > 0 })),
|
|
382
|
+
roll: hand.throws,
|
|
383
|
+
sides: 6,
|
|
384
|
+
// Between the first roll and the last, your dice can be kept back or let go.
|
|
385
|
+
tap: mine && hand.rolls > 0 && hand.rolls < YACHT_ROLLS ? { type: "hold", dice: hand.dice.map((_, id) => id) } : null,
|
|
386
|
+
// Everyone at once: these are this seat's own dice, and nobody else's view has them.
|
|
387
|
+
...(all ? { owner: seat } : {}),
|
|
388
|
+
};
|
|
389
|
+
const left = YACHT_ROLLS - hand.rolls;
|
|
390
|
+
const stuck = hand.rolls > 0 && hand.held.every(Boolean);
|
|
391
|
+
const w = over ? winnersOf(s) : [];
|
|
392
|
+
return {
|
|
393
|
+
game: "yacht",
|
|
394
|
+
seats: s.seats,
|
|
395
|
+
me: seat,
|
|
396
|
+
teams: false,
|
|
397
|
+
phase: s.phase,
|
|
398
|
+
turn: over || all ? null : s.turn,
|
|
399
|
+
grids: [],
|
|
400
|
+
reserves: [],
|
|
401
|
+
dice,
|
|
402
|
+
sheet: sheetView(s, seat),
|
|
403
|
+
choices: mine
|
|
404
|
+
? {
|
|
405
|
+
prompt: hand.rolls === 0 ? "" : left === 0 ? "Pick a box to score" : stuck ? "Pick a box to score, or let go of a die to roll again" : "Tap dice to keep them, or pick a box to score",
|
|
406
|
+
options: left > 0 && !stuck ? [{ label: hand.rolls === 0 ? "Roll the dice" : `Roll again (${left} left)`, move: { type: "roll" }, primary: true }] : [],
|
|
407
|
+
}
|
|
408
|
+
: null,
|
|
409
|
+
players: s.sheets.map((_, i) => ({
|
|
410
|
+
line: [`${yachtTotal(s, i)} points`, ...(rules.drinking ? [`🍺 ${s.tally.drank[i]}`] : [])].join(" · "),
|
|
411
|
+
badge: all && !over && handOf(s, i).done ? "Scored" : undefined,
|
|
412
|
+
})),
|
|
413
|
+
info: [
|
|
414
|
+
over ? `${rounds} rounds played` : `Round ${s.round} of ${rounds}`,
|
|
415
|
+
...(all && !over ? ["Everyone rolls at once"] : []),
|
|
416
|
+
...(rules.drinking && !over ? ["🍺 Five of a kind: the others drink", "🍺 A zero: you drink"] : []),
|
|
417
|
+
],
|
|
418
|
+
narration: s.narration,
|
|
419
|
+
scores: totals(s).map((value, i) => ({ seats: [i], value })),
|
|
420
|
+
result: over ? resultOf(s) : null,
|
|
421
|
+
over: over ? { winners: w, title: overTitle(s, w) } : null,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const keyOf = (counts) => counts.reduce((key, c, face) => (face ? key * 6 + c : 0), 0);
|
|
425
|
+
let TABLES = null;
|
|
426
|
+
function tables() {
|
|
427
|
+
if (TABLES)
|
|
428
|
+
return TABLES;
|
|
429
|
+
const keepList = [];
|
|
430
|
+
const fill = (face, counts, size) => {
|
|
431
|
+
if (face > 6)
|
|
432
|
+
return void keepList.push({ counts: [...counts], size });
|
|
433
|
+
for (let n = 0; size + n <= YACHT_DICE; n++) {
|
|
434
|
+
counts[face] = n;
|
|
435
|
+
fill(face + 1, counts, size + n);
|
|
436
|
+
}
|
|
437
|
+
counts[face] = 0;
|
|
438
|
+
};
|
|
439
|
+
fill(1, [0, 0, 0, 0, 0, 0, 0], 0);
|
|
440
|
+
const rolls = keepList.filter((k) => k.size === YACHT_DICE);
|
|
441
|
+
const rollIndex = new Map(rolls.map((r, i) => [keyOf(r.counts), i]));
|
|
442
|
+
const keepIndex = new Map(keepList.map((k, i) => [keyOf(k.counts), i]));
|
|
443
|
+
const factorial = [1, 1, 2, 6, 24, 120];
|
|
444
|
+
const to = [];
|
|
445
|
+
const chance = [];
|
|
446
|
+
for (const keep of keepList) {
|
|
447
|
+
const thrown = YACHT_DICE - keep.size;
|
|
448
|
+
const targets = [];
|
|
449
|
+
const odds = [];
|
|
450
|
+
// Every way the dice that are thrown can fall, with how many orders of throwing give it.
|
|
451
|
+
for (const fall of keepList) {
|
|
452
|
+
if (fall.size !== thrown)
|
|
453
|
+
continue;
|
|
454
|
+
const ways = fall.counts.reduce((n, c) => n / factorial[c], factorial[thrown]);
|
|
455
|
+
targets.push(rollIndex.get(keyOf(keep.counts.map((c, face) => c + fall.counts[face]))));
|
|
456
|
+
odds.push(ways / 6 ** thrown);
|
|
457
|
+
}
|
|
458
|
+
to.push(Int16Array.from(targets));
|
|
459
|
+
chance.push(Float64Array.from(odds));
|
|
460
|
+
}
|
|
461
|
+
const keeps = rolls.map((roll) => Int16Array.from(keepList.flatMap((k) => (k.counts.every((c, face) => c <= roll.counts[face]) ? [keepIndex.get(keyOf(k.counts))] : []))));
|
|
462
|
+
return (TABLES = { rolls, to, chance, keeps, keepList, rollIndex });
|
|
463
|
+
}
|
|
464
|
+
/** How much a point towards the top half's bonus is worth, above or below three of each face. */
|
|
465
|
+
const BONUS_WEIGHT = 1;
|
|
466
|
+
/** What a score in a box is worth to the computer player: the points, less what the box usually fetches. */
|
|
467
|
+
function worth(box, score, chasing) {
|
|
468
|
+
const course = chasing && box.face !== undefined ? (score - 3 * box.face) * BONUS_WEIGHT : 0;
|
|
469
|
+
return score - box.par + course;
|
|
470
|
+
}
|
|
471
|
+
/** Whether the top half's bonus is still to play for: not won, and not out of reach. */
|
|
472
|
+
function chasingBonus(s, seat) {
|
|
473
|
+
const { boxes, bonus, bonusAt } = sheetOf(s);
|
|
474
|
+
if (!bonus)
|
|
475
|
+
return false;
|
|
476
|
+
const { sum } = upperOf(s, seat);
|
|
477
|
+
const reach = boxes.reduce((n, b, i) => n + (b.face !== undefined && s.sheets[seat][i] === null ? b.face * YACHT_DICE : 0), 0);
|
|
478
|
+
return sum < bonusAt && sum + reach >= bonusAt;
|
|
479
|
+
}
|
|
480
|
+
/** The best box for these dice and what it is worth. */
|
|
481
|
+
function bestBox(s, seat, counts, chasing) {
|
|
482
|
+
const { boxes } = sheetOf(s);
|
|
483
|
+
let best = { box: -1, value: -Infinity };
|
|
484
|
+
for (const o of optionsFor(s, seat, counts)) {
|
|
485
|
+
const value = worth(boxes[o.box], o.score, chasing);
|
|
486
|
+
if (value > best.value)
|
|
487
|
+
best = { box: o.box, value };
|
|
488
|
+
}
|
|
489
|
+
// An extra Yacht's bonus comes whichever box it goes in.
|
|
490
|
+
if (jokerOf(s, seat, counts) && s.sheets[seat][boxes.findIndex((b) => b.id === "yacht")] === 50)
|
|
491
|
+
best.value += YACHT_BONUS;
|
|
492
|
+
return best;
|
|
493
|
+
}
|
|
494
|
+
/** What the sharpest player would do with a seat's dice now: the box to score, or the dice to keep and roll again. */
|
|
495
|
+
export function yachtPlan(s, seat) {
|
|
496
|
+
const hand = handOf(s, seat);
|
|
497
|
+
if (!canPlay(s, seat) || hand.rolls === 0)
|
|
498
|
+
return null;
|
|
499
|
+
const t = tables();
|
|
500
|
+
const { boxes } = sheetOf(s);
|
|
501
|
+
const chasing = chasingBonus(s, seat);
|
|
502
|
+
const counts = countsOf(hand.dice);
|
|
503
|
+
const now = bestBox(s, seat, counts, chasing);
|
|
504
|
+
const stop = { box: boxes[now.box].id, keep: null, value: now.value };
|
|
505
|
+
const left = YACHT_ROLLS - hand.rolls;
|
|
506
|
+
if (left <= 0)
|
|
507
|
+
return stop;
|
|
508
|
+
// What every final roll is worth, then what every keep is worth with a roll to come.
|
|
509
|
+
let final = Float64Array.from(t.rolls, (r) => bestBox(s, seat, r.counts, chasing).value);
|
|
510
|
+
const average = (values) => Float64Array.from(t.to, (targets, keep) => {
|
|
511
|
+
let sum = 0;
|
|
512
|
+
for (let i = 0; i < targets.length; i++)
|
|
513
|
+
sum += t.chance[keep][i] * values[targets[i]];
|
|
514
|
+
return sum;
|
|
515
|
+
});
|
|
516
|
+
let keeping = average(final);
|
|
517
|
+
if (left > 1) {
|
|
518
|
+
// With two rolls to come, a roll is worth the better of stopping there and its best keep.
|
|
519
|
+
final = final.map((value, roll) => t.keeps[roll].reduce((best, keep) => Math.max(best, keeping[keep]), value));
|
|
520
|
+
keeping = average(final);
|
|
521
|
+
}
|
|
522
|
+
const here = t.rollIndex.get(keyOf(counts));
|
|
523
|
+
let best = -1;
|
|
524
|
+
for (const keep of t.keeps[here])
|
|
525
|
+
if (t.keepList[keep].size < YACHT_DICE && (best < 0 || keeping[keep] > keeping[best]))
|
|
526
|
+
best = keep;
|
|
527
|
+
// Rolling has to beat stopping by a whisker: with nothing in it, take the points.
|
|
528
|
+
return keeping[best] > now.value + 1e-9 ? { box: null, keep: t.keepList[best].counts, value: keeping[best] } : stop;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* What someone not really thinking does: keeps whatever they have most of and rolls the rest, takes
|
|
532
|
+
* anything worth 25 as soon as it turns up, and at the end puts the dice wherever they score most
|
|
533
|
+
* today, with no thought for what the box might have been worth later.
|
|
534
|
+
*/
|
|
535
|
+
function casualPlan(s, seat) {
|
|
536
|
+
const hand = handOf(s, seat);
|
|
537
|
+
const { boxes } = sheetOf(s);
|
|
538
|
+
const counts = countsOf(hand.dice);
|
|
539
|
+
const top = optionsFor(s, seat, counts).reduce((a, b) => (b.score > a.score ? b : a));
|
|
540
|
+
const most = Math.max(...counts);
|
|
541
|
+
if (hand.rolls >= YACHT_ROLLS || top.score >= 25 || most === YACHT_DICE)
|
|
542
|
+
return { box: boxes[top.box].id, keep: null, value: top.score };
|
|
543
|
+
const keep = counts.map((c, face) => (most > 1 && face === counts.lastIndexOf(most) ? c : 0));
|
|
544
|
+
return { box: null, keep, value: 0 };
|
|
545
|
+
}
|
|
546
|
+
function bot(s, seat, style) {
|
|
547
|
+
if (!canPlay(s, seat))
|
|
548
|
+
return null;
|
|
549
|
+
const hand = handOf(s, seat);
|
|
550
|
+
if (hand.rolls === 0)
|
|
551
|
+
return { type: "roll" };
|
|
552
|
+
// Keeping dice takes a move a die, so the plan has to be the same one each time it's asked for
|
|
553
|
+
// between two rolls: whether this is a careless moment is decided by the dice, not by the clock.
|
|
554
|
+
const rand = mulberry32((s.rng ^ Math.imul(hand.throws + 1, 0x9e3779b1) ^ Math.imul(seat + 1, 0x85ebca6b)) >>> 0);
|
|
555
|
+
const plan = careless(style, rand) ? casualPlan(s, seat) : yachtPlan(s, seat);
|
|
556
|
+
if (plan.box !== null)
|
|
557
|
+
return { type: "score", cell: plan.box };
|
|
558
|
+
// Which dice to keep: the ones already kept first, so as few as possible change hands.
|
|
559
|
+
const want = [...plan.keep];
|
|
560
|
+
const keep = Array(YACHT_DICE).fill(false);
|
|
561
|
+
for (const held of [true, false]) {
|
|
562
|
+
hand.dice.forEach((value, die) => {
|
|
563
|
+
if (hand.held[die] === held && want[value] > 0) {
|
|
564
|
+
keep[die] = true;
|
|
565
|
+
want[value] -= 1;
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const die = keep.findIndex((k, i) => k !== hand.held[i]);
|
|
570
|
+
return die >= 0 ? { type: "hold", die } : { type: "roll" };
|
|
571
|
+
}
|
|
572
|
+
// ---- The game -----------------------------------------------------------------------------------
|
|
573
|
+
function normalize(rules) {
|
|
574
|
+
const given = (rules && typeof rules === "object" ? rules : {});
|
|
575
|
+
const base = PRESETS.find((p) => p.id === given.id) ?? classicYacht;
|
|
576
|
+
const r = { ...base, ...given };
|
|
577
|
+
const sheet = r.sheet === "yatzy" || r.sheet === "quick" ? r.sheet : "classic";
|
|
578
|
+
return {
|
|
579
|
+
id: String(r.id),
|
|
580
|
+
name: String(r.name),
|
|
581
|
+
sheet,
|
|
582
|
+
// Yatzy has no jokers: five of a kind is one box and that's that.
|
|
583
|
+
bonus: sheet !== "yatzy" && !!r.bonus,
|
|
584
|
+
order: r.order === "together" ? "together" : "turns",
|
|
585
|
+
drinking: !!r.drinking,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const SHOTZEE_HINT = "Adults only. The table is Shotzee: roll five of a kind and everyone else takes a shot; a zero on your sheet costs you a drink (water counts).";
|
|
589
|
+
export const yacht = {
|
|
590
|
+
id: "yacht",
|
|
591
|
+
name: "Yacht",
|
|
592
|
+
emoji: "🎲",
|
|
593
|
+
blurb: "Five dice, three rolls, one scorecard",
|
|
594
|
+
tagline: "Roll five dice up to three times, then pick the box they score best in. Fill your sheet with the highest total.",
|
|
595
|
+
seats: { min: 1, max: 6, default: 2 },
|
|
596
|
+
teams: false,
|
|
597
|
+
presets: PRESETS,
|
|
598
|
+
fields: [
|
|
599
|
+
{ key: "sheet", label: "Scorecard", kind: "choice", options: [["classic", "Classic: thirteen boxes"], ["yatzy", "Yatzy: fifteen boxes, with pairs"], ["quick", "Quick: the bottom seven"]] },
|
|
600
|
+
{ key: "bonus", label: "Extra Yachts", hint: "Classic and Quick. Once your Yacht box is filled, another five of a kind is a joker (a full house or a straight if you need one), and worth 100 more if the box holds 50.", kind: "toggle" },
|
|
601
|
+
{ key: "order", label: "Who rolls when", hint: "Everyone at once: each player has their own dice and nobody waits for a turn.", kind: "choice", options: [["turns", "In turn"], ["together", "Everyone at once"]] },
|
|
602
|
+
drinkingField(SHOTZEE_HINT),
|
|
603
|
+
],
|
|
604
|
+
normalize,
|
|
605
|
+
validate: () => [],
|
|
606
|
+
describe(r) {
|
|
607
|
+
const { boxes, bonus, bonusAt } = SHEETS[r.sheet];
|
|
608
|
+
const five = yachtName(r);
|
|
609
|
+
return [
|
|
610
|
+
`On your turn, roll five dice. Keep any you like and roll the rest again, up to ${YACHT_ROLLS} rolls in all. Then the dice go in one empty box of your sheet, for what they score there, or for nothing if they don't fit.`,
|
|
611
|
+
`There are ${boxes.length} boxes, and so ${boxes.length} rounds. When every sheet is full, the highest total wins.`,
|
|
612
|
+
...(bonus ? [`The top half: add up the dice of one number. ${bonusAt} or more over the six boxes (three of each) earns a bonus of ${bonus}.`] : []),
|
|
613
|
+
r.sheet === "yatzy"
|
|
614
|
+
? `The bottom half: one pair and two pairs (add the pairs), three and four of a kind (add those dice), a small straight (1 to 5) for 15, a large straight (2 to 6) for 20, a full house (add all five), Chance (add all five), and ${five}, five of a kind, for 50.`
|
|
615
|
+
: `${bonus ? "The bottom half" : "The sheet"}: three and four of a kind (add all five dice), a full house for 25, a small straight (four in a row) for 30, a large straight (five in a row) for 40, ${five}, five of a kind, for 50, and Chance (add all five).`,
|
|
616
|
+
...(r.sheet !== "yatzy"
|
|
617
|
+
? [
|
|
618
|
+
r.bonus
|
|
619
|
+
? `Extra ${five}s: once your ${five} box is filled, another five of a kind is a joker. It has to go in its own number${bonus ? " in the top half if that's empty; otherwise" : ", or"} anywhere in the bottom half, where it counts as a full house or either straight. If your ${five} box holds 50, it's worth ${YACHT_BONUS} more.`
|
|
620
|
+
: `A second ${five} is nothing special: it scores what the box it goes in says.`,
|
|
621
|
+
]
|
|
622
|
+
: []),
|
|
623
|
+
r.order === "together" ? "Everyone rolls at once, each with dice of their own that nobody else sees. When everyone has scored, the next round starts." : "Players take turns, and everyone watches the dice.",
|
|
624
|
+
...(r.drinking ? [`🍺 Drinking mode: this is Shotzee. Roll five of a kind and everyone else at the table takes a shot. Put a zero in any box and you take a drink.`] : []),
|
|
625
|
+
];
|
|
626
|
+
},
|
|
627
|
+
newGame: newYacht,
|
|
628
|
+
apply,
|
|
629
|
+
view,
|
|
630
|
+
bot,
|
|
631
|
+
thinkingTime(s, seat) {
|
|
632
|
+
// Rolling is quick, setting a die aside quicker; choosing a box is the part people think about.
|
|
633
|
+
const next = bot(s, seat, {});
|
|
634
|
+
return next?.type === "score" ? 1300 : next?.type === "hold" ? 420 : 800;
|
|
635
|
+
},
|
|
636
|
+
summary(s) {
|
|
637
|
+
const over = s.phase === "finished";
|
|
638
|
+
const all = totals(s);
|
|
639
|
+
return {
|
|
640
|
+
game: "yacht",
|
|
641
|
+
rules: s.rules.name,
|
|
642
|
+
phase: s.phase,
|
|
643
|
+
over,
|
|
644
|
+
hand: s.round,
|
|
645
|
+
scoreLine: all.join(" · ") + (s.seats === 1 ? " points" : "") + (s.rules.drinking ? ` · 🍺 ${s.tally.drank.join(" · ")}` : ""),
|
|
646
|
+
order: over ? rankSeats(all.map((score, seat) => ({ seats: [seat], score }))) : null,
|
|
647
|
+
winners: over ? winnersOf(s) : null,
|
|
648
|
+
teams: false,
|
|
649
|
+
seats: s.seats,
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
//# sourceMappingURL=yacht.js.map
|