@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.
- 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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type BoardGame } from "./board.js";
|
|
2
|
+
import { type Tally } from "./drinks.js";
|
|
3
|
+
import { type BaseRules } from "./table.js";
|
|
4
|
+
export interface DropFourRules extends BaseRules {
|
|
5
|
+
/** Columns across the rack. */
|
|
6
|
+
cols: number;
|
|
7
|
+
/** How many discs a column holds. */
|
|
8
|
+
rows: number;
|
|
9
|
+
/** How many in a line win. More than that in a line wins too. */
|
|
10
|
+
line: number;
|
|
11
|
+
/** Pop Out: on your turn you may take one of your own discs out of the bottom row instead. */
|
|
12
|
+
popOut: boolean;
|
|
13
|
+
/** Who plays red (and so goes first) in the first game. */
|
|
14
|
+
first: "random" | "seat1" | "seat2";
|
|
15
|
+
/** Games in a match: most wins takes it. */
|
|
16
|
+
games: number;
|
|
17
|
+
/** Drinking mode: the shot clock. */
|
|
18
|
+
drinking: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare const classicDropFour: DropFourRules;
|
|
21
|
+
export declare const bigDropFour: DropFourRules;
|
|
22
|
+
export declare const popOutDropFour: DropFourRules;
|
|
23
|
+
export declare const fiveDropFour: DropFourRules;
|
|
24
|
+
export declare const drinkingDropFour: DropFourRules;
|
|
25
|
+
/** Drinking mode: how long you have to drop a disc once the other player's has landed, in ms. */
|
|
26
|
+
export declare const SHOT_CLOCK_MS = 3000;
|
|
27
|
+
/** A place in the rack: the seat whose disc is there, or null. */
|
|
28
|
+
type Mark = number | null;
|
|
29
|
+
export interface DropFourState {
|
|
30
|
+
game: "dropfour";
|
|
31
|
+
rules: DropFourRules;
|
|
32
|
+
seats: number;
|
|
33
|
+
rng: number;
|
|
34
|
+
/** Which game of the match, from 1. */
|
|
35
|
+
round: number;
|
|
36
|
+
/** Who plays red (and so goes first) in this game. */
|
|
37
|
+
first: number;
|
|
38
|
+
/** Row by row from the top: rows × cols. */
|
|
39
|
+
cells: Mark[];
|
|
40
|
+
turn: number;
|
|
41
|
+
/** Where the last disc landed; null at the start and after a pop. */
|
|
42
|
+
last: number | null;
|
|
43
|
+
/** The bottom of the column a disc was just popped out of; null otherwise. */
|
|
44
|
+
popped: number | null;
|
|
45
|
+
/** Discs dropped and popped so far this game. */
|
|
46
|
+
moves: number;
|
|
47
|
+
/**
|
|
48
|
+
* Drinking mode: the shot clock is running on whoever's turn it is. It starts when the other
|
|
49
|
+
* player's disc lands, so not on the first move of a game, and not after a turn lost to the clock
|
|
50
|
+
* (two people who have both walked away don't go on missing turns for ever).
|
|
51
|
+
*/
|
|
52
|
+
clock: boolean;
|
|
53
|
+
/** The cells of the winning line, once there is one. */
|
|
54
|
+
won: number[] | null;
|
|
55
|
+
/** Who won the game just played; null for a draw (or while playing). */
|
|
56
|
+
roundWinner: number | null;
|
|
57
|
+
wins: number[];
|
|
58
|
+
draws: number;
|
|
59
|
+
/** Drinks taken for running out the shot clock. */
|
|
60
|
+
tally: Tally;
|
|
61
|
+
phase: "playing" | "scored" | "finished";
|
|
62
|
+
ready: boolean[];
|
|
63
|
+
narration: string | null;
|
|
64
|
+
}
|
|
65
|
+
export type DropFourMove = {
|
|
66
|
+
type: "drop";
|
|
67
|
+
col: number;
|
|
68
|
+
}
|
|
69
|
+
/** Pop Out: `from` is your disc in the bottom row (`to` is the same cell: you tap it twice). */
|
|
70
|
+
| {
|
|
71
|
+
type: "pop";
|
|
72
|
+
from: number;
|
|
73
|
+
to?: number;
|
|
74
|
+
}
|
|
75
|
+
/** The shot clock ran out: made by the table for the seat whose turn it was. */
|
|
76
|
+
| {
|
|
77
|
+
type: "timeout";
|
|
78
|
+
} | {
|
|
79
|
+
type: "next";
|
|
80
|
+
};
|
|
81
|
+
/** A Pop Out game could go round for ever: one that gets this long with no winner is a draw. */
|
|
82
|
+
export declare const moveLimit: (r: DropFourRules) => number;
|
|
83
|
+
export declare function newDropFour(rules: DropFourRules, seats: number, seed: number): DropFourState;
|
|
84
|
+
type Rack = Pick<DropFourState, "rules" | "cells" | "phase">;
|
|
85
|
+
/** Where a disc dropped into `col` would land, or null when the column is full. */
|
|
86
|
+
export declare function landing(s: Pick<DropFourState, "rules" | "cells">, col: number): number | null;
|
|
87
|
+
/** The columns a disc can be dropped into now. */
|
|
88
|
+
export declare function openColumns(s: Rack): number[];
|
|
89
|
+
/** Pop Out: the columns `seat` can pop a disc out of now (their own disc is at the bottom). */
|
|
90
|
+
export declare function poppable(s: Rack, seat: number): number[];
|
|
91
|
+
/**
|
|
92
|
+
* The best move for `seat`: looks one move deeper each time round, up to `depth` moves or until
|
|
93
|
+
* `budget` positions have been looked at (the last, unfinished look is thrown away).
|
|
94
|
+
*/
|
|
95
|
+
export declare function bestDropFourMove(s: DropFourState, seat: number, depth: number, budget: number, rand?: () => number): {
|
|
96
|
+
move: number;
|
|
97
|
+
value: number;
|
|
98
|
+
depth: number;
|
|
99
|
+
nodes: number;
|
|
100
|
+
};
|
|
101
|
+
/** Drop Four, with resigning, draw offers and taking a move back (see offers.ts). */
|
|
102
|
+
export declare const dropFour: BoardGame<DropFourRules & import("./offers.js").OfferRules, import("./offers.js").WithOffers<DropFourState>, import("./offers.js").OfferMove | DropFourMove>;
|
|
103
|
+
export {};
|
package/dist/dropfour.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { settleMatch, withOffers } from "./offers.js";
|
|
2
|
+
import { cellAt, colOf, lineThrough } from "./board.js";
|
|
3
|
+
import { drink, newTally } from "./drinks.js";
|
|
4
|
+
import { drinkingField } from "./kit.js";
|
|
5
|
+
import { careless, clone, GameError, markReady, nextHandChoices, nextRandom, pickRandom, thinkFor, who } from "./table.js";
|
|
6
|
+
export const classicDropFour = { id: "dropfour-classic", name: "Classic (7×6)", cols: 7, rows: 6, line: 4, popOut: false, first: "random", games: 1, drinking: false };
|
|
7
|
+
export const bigDropFour = { ...classicDropFour, id: "dropfour-big", name: "Big board (9×7)", cols: 9, rows: 7 };
|
|
8
|
+
export const popOutDropFour = { ...classicDropFour, id: "dropfour-popout", name: "Pop Out", popOut: true };
|
|
9
|
+
export const fiveDropFour = { ...classicDropFour, id: "dropfour-five", name: "Five in a row (9×6)", cols: 9, line: 5 };
|
|
10
|
+
export const drinkingDropFour = { ...classicDropFour, id: "dropfour-drinking", name: "Drinking Drop Four 🍺", drinking: true };
|
|
11
|
+
const PRESETS = [classicDropFour, bigDropFour, popOutDropFour, fiveDropFour, drinkingDropFour];
|
|
12
|
+
const COLS = [4, 5, 6, 7, 8, 9, 10];
|
|
13
|
+
const ROWS = [4, 5, 6, 7, 8];
|
|
14
|
+
/** Drinking mode: how long you have to drop a disc once the other player's has landed, in ms. */
|
|
15
|
+
export const SHOT_CLOCK_MS = 3000;
|
|
16
|
+
const other = (seat) => 1 - seat;
|
|
17
|
+
const bottom = (r, col) => cellAt(r.cols, r.rows - 1, col);
|
|
18
|
+
/** A Pop Out game could go round for ever: one that gets this long with no winner is a draw. */
|
|
19
|
+
export const moveLimit = (r) => r.cols * r.rows * 4;
|
|
20
|
+
function startRound(s) {
|
|
21
|
+
s.cells = Array(s.rules.rows * s.rules.cols).fill(null);
|
|
22
|
+
s.turn = s.first;
|
|
23
|
+
s.last = null;
|
|
24
|
+
s.popped = null;
|
|
25
|
+
s.moves = 0;
|
|
26
|
+
s.clock = false;
|
|
27
|
+
s.won = null;
|
|
28
|
+
s.roundWinner = null;
|
|
29
|
+
s.phase = "playing";
|
|
30
|
+
s.ready = Array(s.seats).fill(false);
|
|
31
|
+
s.narration = null;
|
|
32
|
+
}
|
|
33
|
+
export function newDropFour(rules, seats, seed) {
|
|
34
|
+
const s = { game: "dropfour", rules, seats: 2, rng: seed >>> 0, round: 1, wins: [0, 0], draws: 0, tally: newTally(2) };
|
|
35
|
+
s.first = rules.first === "seat1" ? 0 : rules.first === "seat2" ? 1 : nextRandom(s)() < 0.5 ? 0 : 1;
|
|
36
|
+
startRound(s);
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
/** Where a disc dropped into `col` would land, or null when the column is full. */
|
|
40
|
+
export function landing(s, col) {
|
|
41
|
+
for (let row = s.rules.rows - 1; row >= 0; row--)
|
|
42
|
+
if (s.cells[cellAt(s.rules.cols, row, col)] === null)
|
|
43
|
+
return cellAt(s.rules.cols, row, col);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/** The columns a disc can be dropped into now. */
|
|
47
|
+
export function openColumns(s) {
|
|
48
|
+
if (s.phase !== "playing")
|
|
49
|
+
return [];
|
|
50
|
+
return Array.from({ length: s.rules.cols }, (_, col) => col).filter((col) => s.cells[col] === null);
|
|
51
|
+
}
|
|
52
|
+
/** Pop Out: the columns `seat` can pop a disc out of now (their own disc is at the bottom). */
|
|
53
|
+
export function poppable(s, seat) {
|
|
54
|
+
if (s.phase !== "playing" || !s.rules.popOut)
|
|
55
|
+
return [];
|
|
56
|
+
return Array.from({ length: s.rules.cols }, (_, col) => col).filter((col) => s.cells[bottom(s.rules, col)] === seat);
|
|
57
|
+
}
|
|
58
|
+
const lineAt = (s, cell, seat) => lineThrough(s.rules.rows, s.rules.cols, cell, s.rules.line, (c) => s.cells[c] === seat);
|
|
59
|
+
// ---- Moves --------------------------------------------------------------------------------------
|
|
60
|
+
/** How a match stands once this game is in: finished when the games run out or one side can't be caught. */
|
|
61
|
+
function matchOver(s) {
|
|
62
|
+
const left = s.rules.games - s.round;
|
|
63
|
+
return left <= 0 || Math.abs(s.wins[0] - s.wins[1]) > left;
|
|
64
|
+
}
|
|
65
|
+
/** The game just ended: `winner` took it, or nobody did. */
|
|
66
|
+
function endRound(s, winner) {
|
|
67
|
+
const many = s.rules.games > 1;
|
|
68
|
+
s.clock = false;
|
|
69
|
+
s.roundWinner = winner;
|
|
70
|
+
if (winner === null) {
|
|
71
|
+
s.draws += 1;
|
|
72
|
+
s.narration = many ? `Game ${s.round} is a draw` : "It's a draw!";
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
s.wins[winner] += 1;
|
|
76
|
+
s.narration = `${who(winner)} wins${many ? ` game ${s.round}` : ""}!`;
|
|
77
|
+
}
|
|
78
|
+
s.phase = matchOver(s) ? "finished" : "scored";
|
|
79
|
+
}
|
|
80
|
+
/** After a disc has been dropped or popped with no line made: the other player's turn, or a draw. */
|
|
81
|
+
function passTurn(s, seat) {
|
|
82
|
+
s.moves += 1;
|
|
83
|
+
const next = other(seat);
|
|
84
|
+
// A full rack is a draw, unless the next player can pop a disc out of it.
|
|
85
|
+
const stuck = openColumns(s).length === 0 && poppable(s, next).length === 0;
|
|
86
|
+
if (stuck || (s.rules.popOut && s.moves >= moveLimit(s.rules)))
|
|
87
|
+
return endRound(s, null);
|
|
88
|
+
s.turn = next;
|
|
89
|
+
s.clock = s.rules.drinking;
|
|
90
|
+
}
|
|
91
|
+
function apply(state, seat, move) {
|
|
92
|
+
if (move?.type === "next") {
|
|
93
|
+
if (state.phase !== "scored")
|
|
94
|
+
throw new GameError("There's a game going on.");
|
|
95
|
+
const s = clone(state);
|
|
96
|
+
if (markReady(s.ready, seat)) {
|
|
97
|
+
// Take turns going first.
|
|
98
|
+
s.round += 1;
|
|
99
|
+
s.first = other(s.first);
|
|
100
|
+
startRound(s);
|
|
101
|
+
}
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
if (move?.type !== "drop" && move?.type !== "pop" && move?.type !== "timeout")
|
|
105
|
+
throw new GameError("That's not a move in Drop Four.");
|
|
106
|
+
if (state.phase !== "playing")
|
|
107
|
+
throw new GameError("This game is over.");
|
|
108
|
+
if (state.turn !== seat)
|
|
109
|
+
throw new GameError("It's not your turn.");
|
|
110
|
+
const { rules } = state;
|
|
111
|
+
if (move.type === "timeout") {
|
|
112
|
+
if (!state.clock)
|
|
113
|
+
throw new GameError("There's no clock running.");
|
|
114
|
+
const s = clone(state);
|
|
115
|
+
s.narration = `${who(seat)} was too slow · ${drink(s.tally, [seat], 1)}`;
|
|
116
|
+
s.turn = other(seat);
|
|
117
|
+
s.clock = false;
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
if (move.type === "drop") {
|
|
121
|
+
const col = Number(move.col);
|
|
122
|
+
if (!Number.isInteger(col) || col < 0 || col >= rules.cols)
|
|
123
|
+
throw new GameError("That's not a column.");
|
|
124
|
+
const cell = landing(state, col);
|
|
125
|
+
if (cell === null)
|
|
126
|
+
throw new GameError("That column is full.");
|
|
127
|
+
const s = clone(state);
|
|
128
|
+
s.cells[cell] = seat;
|
|
129
|
+
s.last = cell;
|
|
130
|
+
s.popped = null;
|
|
131
|
+
s.narration = `${who(seat)} drops ${discOf(s, seat).label} in column ${col + 1}`;
|
|
132
|
+
const line = lineAt(s, cell, seat);
|
|
133
|
+
if (line) {
|
|
134
|
+
s.won = line;
|
|
135
|
+
endRound(s, seat);
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
passTurn(s, seat);
|
|
139
|
+
return s;
|
|
140
|
+
}
|
|
141
|
+
if (!rules.popOut)
|
|
142
|
+
throw new GameError("Popping a disc out isn't in these rules.");
|
|
143
|
+
const from = Number(move.from);
|
|
144
|
+
if (!Number.isInteger(from) || from < 0 || from >= state.cells.length)
|
|
145
|
+
throw new GameError("That's not in the rack.");
|
|
146
|
+
const col = colOf(rules.cols, from);
|
|
147
|
+
if (from !== bottom(rules, col) || (move.to !== undefined && Number(move.to) !== from))
|
|
148
|
+
throw new GameError("You can only pop a disc out of the bottom row.");
|
|
149
|
+
if (state.cells[from] !== seat)
|
|
150
|
+
throw new GameError("You can only pop out a disc of your own.");
|
|
151
|
+
const s = clone(state);
|
|
152
|
+
for (let row = rules.rows - 1; row > 0; row--)
|
|
153
|
+
s.cells[cellAt(rules.cols, row, col)] = s.cells[cellAt(rules.cols, row - 1, col)] ?? null;
|
|
154
|
+
s.cells[col] = null;
|
|
155
|
+
s.last = null;
|
|
156
|
+
s.popped = from;
|
|
157
|
+
s.narration = `${who(seat)} pops a disc out of column ${col + 1}`;
|
|
158
|
+
// Everything in the column moved, so either colour may have a line now. Both at once goes to
|
|
159
|
+
// whoever popped.
|
|
160
|
+
for (const side of [seat, other(seat)]) {
|
|
161
|
+
for (let row = rules.rows - 1; row >= 0 && !s.won; row--) {
|
|
162
|
+
const cell = cellAt(rules.cols, row, col);
|
|
163
|
+
if (s.cells[cell] === side)
|
|
164
|
+
s.won = lineAt(s, cell, side);
|
|
165
|
+
}
|
|
166
|
+
if (s.won) {
|
|
167
|
+
endRound(s, side);
|
|
168
|
+
return s;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
passTurn(s, seat);
|
|
172
|
+
return s;
|
|
173
|
+
}
|
|
174
|
+
/** Best first. A drawn match lists the player who went second in the last game first: holding red to a draw is the better result. */
|
|
175
|
+
function order(s) {
|
|
176
|
+
const [a, b] = s.wins;
|
|
177
|
+
if (a !== b)
|
|
178
|
+
return a > b ? [0, 1] : [1, 0];
|
|
179
|
+
return [other(s.first), s.first];
|
|
180
|
+
}
|
|
181
|
+
const winners = (s) => (s.wins[0] === s.wins[1] ? [0, 1] : [s.wins[0] > s.wins[1] ? 0 : 1]);
|
|
182
|
+
// ---- What a seat sees ---------------------------------------------------------------------------
|
|
183
|
+
const RED = { kind: "disc", glyph: "●", label: "red", tone: "red" };
|
|
184
|
+
const YELLOW = { kind: "disc", glyph: "●", label: "yellow", tone: "gold" };
|
|
185
|
+
const discOf = (s, seat) => ({ ...(seat === s.first ? RED : YELLOW), owner: seat });
|
|
186
|
+
const NUMBER_WORDS = ["Three", "Four", "Five", "Six"];
|
|
187
|
+
const goal = (r) => `${NUMBER_WORDS[r.line - 3] ?? r.line} in a row wins`;
|
|
188
|
+
const CLOCK_LINE = `🍺 ${SHOT_CLOCK_MS / 1000} seconds to drop, or drink and lose your turn`;
|
|
189
|
+
function view(s, seat) {
|
|
190
|
+
const { rules } = s;
|
|
191
|
+
const over = s.phase === "finished";
|
|
192
|
+
const mine = s.phase === "playing" && s.turn === seat;
|
|
193
|
+
const many = rules.games > 1;
|
|
194
|
+
const w = over ? winners(s) : [];
|
|
195
|
+
const marks = [];
|
|
196
|
+
if (s.last !== null)
|
|
197
|
+
marks.push({ cell: s.last, mark: "last" });
|
|
198
|
+
if (s.popped !== null)
|
|
199
|
+
marks.push({ cell: s.popped, mark: "from" });
|
|
200
|
+
for (const cell of s.won ?? [])
|
|
201
|
+
marks.push({ cell, mark: "win" });
|
|
202
|
+
const drops = mine ? openColumns(s) : [];
|
|
203
|
+
const pops = mine ? poppable(s, seat) : [];
|
|
204
|
+
const grid = {
|
|
205
|
+
id: "board",
|
|
206
|
+
label: "Rack",
|
|
207
|
+
rows: rules.rows,
|
|
208
|
+
cols: rules.cols,
|
|
209
|
+
gravity: true,
|
|
210
|
+
cells: s.cells.map((m) => (m === null ? null : discOf(s, m))),
|
|
211
|
+
marks,
|
|
212
|
+
// A whole column is one target; the cells listed are where each disc would land.
|
|
213
|
+
tap: drops.length ? { type: "drop", by: "col", cells: drops.map((col) => landing(s, col)) } : null,
|
|
214
|
+
// Pop Out: pick up one of your own discs in the bottom row, and tap it again to pop it.
|
|
215
|
+
select: pops.length ? { type: "pop", sources: pops.map((col) => ({ from: bottom(rules, col), to: [bottom(rules, col)] })) } : null,
|
|
216
|
+
};
|
|
217
|
+
const scoreTitle = s.roundWinner === null ? `Game ${s.round} is a draw` : `${who(s.roundWinner)} wins game ${s.round}`;
|
|
218
|
+
const line = (i) => [...(many ? [`${s.wins[i]} win${s.wins[i] === 1 ? "" : "s"}`] : []), ...(rules.drinking ? [`🍺 ${s.tally.drank[i]}`] : [])].join(" · ");
|
|
219
|
+
return {
|
|
220
|
+
game: "dropfour",
|
|
221
|
+
seats: s.seats,
|
|
222
|
+
me: seat,
|
|
223
|
+
teams: false,
|
|
224
|
+
phase: s.phase,
|
|
225
|
+
turn: s.phase === "playing" ? s.turn : null,
|
|
226
|
+
grids: [grid],
|
|
227
|
+
reserves: [],
|
|
228
|
+
choices: s.phase === "scored" ? nextHandChoices(s.ready, seat, false, "Next game") : null,
|
|
229
|
+
players: s.wins.map((_, i) => ({ line: line(i), piece: discOf(s, i), badge: s.phase === "playing" && s.moves === 0 && i === s.first ? "Goes first" : undefined })),
|
|
230
|
+
info: [
|
|
231
|
+
goal(rules),
|
|
232
|
+
...(many ? [`Game ${s.round} of ${rules.games}`] : []),
|
|
233
|
+
...(rules.popOut && s.phase === "playing" ? ["Pop Out: tap one of your discs in the bottom row twice to pop it"] : []),
|
|
234
|
+
...(rules.drinking && s.phase === "playing" ? [CLOCK_LINE] : []),
|
|
235
|
+
],
|
|
236
|
+
narration: s.narration ?? (mine ? "You're red: you go first" : null),
|
|
237
|
+
scores: s.wins.map((value, i) => ({ seats: [i], value })),
|
|
238
|
+
result: many && s.phase !== "playing"
|
|
239
|
+
? {
|
|
240
|
+
key: `game-${s.round}`,
|
|
241
|
+
title: scoreTitle,
|
|
242
|
+
sides: [[0], [1]],
|
|
243
|
+
rows: [
|
|
244
|
+
{ label: "Wins", values: s.wins.map(String), tone: "total" },
|
|
245
|
+
...(s.draws ? [{ label: "Draws", values: [String(s.draws), String(s.draws)] }] : []),
|
|
246
|
+
...(rules.drinking ? [{ label: "Drinks 🍺", values: s.tally.drank.map(String) }] : []),
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
: null,
|
|
250
|
+
over: over ? { winners: w, title: w.length > 1 ? "It's a draw!" : `${who(w[0])} wins${many ? ` ${s.wins[w[0]]} to ${s.wins[other(w[0])]}` : ""}!` } : null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// ---- The computer player ------------------------------------------------------------------------
|
|
254
|
+
// A look ahead (alpha-beta) over a small copy of the rack, deeper the sharper the player. What it
|
|
255
|
+
// can't see to the end it scores by counting: every run of `line` places that only one colour is in
|
|
256
|
+
// is one that colour is building, worth far more the fuller it is (so an open three outweighs any
|
|
257
|
+
// number of twos), plus a little for discs near the middle, where more lines pass through.
|
|
258
|
+
//
|
|
259
|
+
// How far it looks is counted in positions, never in milliseconds, so the same seat in the same
|
|
260
|
+
// position always plays the same move and a slow server only makes it slower, not worse. A careless
|
|
261
|
+
// player (see `careless`) drops a disc in whatever column comes to mind.
|
|
262
|
+
const WIN = 1_000_000;
|
|
263
|
+
const windowsFor = new Map();
|
|
264
|
+
function windowsOf(rows, cols, line) {
|
|
265
|
+
const key = `${rows}x${cols}x${line}`;
|
|
266
|
+
let found = windowsFor.get(key);
|
|
267
|
+
if (!found) {
|
|
268
|
+
const out = [];
|
|
269
|
+
for (let r = 0; r < rows; r++) {
|
|
270
|
+
for (let c = 0; c < cols; c++) {
|
|
271
|
+
for (const [dr, dc] of [[0, 1], [1, 0], [1, 1], [1, -1]]) {
|
|
272
|
+
const r2 = r + dr * (line - 1);
|
|
273
|
+
const c2 = c + dc * (line - 1);
|
|
274
|
+
if (r2 < 0 || r2 >= rows || c2 < 0 || c2 >= cols)
|
|
275
|
+
continue;
|
|
276
|
+
for (let k = 0; k < line; k++)
|
|
277
|
+
out.push(cellAt(cols, r + dr * k, c + dc * k));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
found = Int16Array.from(out);
|
|
282
|
+
windowsFor.set(key, found);
|
|
283
|
+
}
|
|
284
|
+
return found;
|
|
285
|
+
}
|
|
286
|
+
function rackOf(s) {
|
|
287
|
+
const { rows, cols, line } = s.rules;
|
|
288
|
+
const cells = Int8Array.from(s.cells, (m) => (m === null ? 0 : m + 1));
|
|
289
|
+
const heights = new Int8Array(cols);
|
|
290
|
+
for (let c = 0; c < cols; c++)
|
|
291
|
+
for (let r = rows - 1; r >= 0 && cells[cellAt(cols, r, c)] !== 0; r--)
|
|
292
|
+
heights[c]++;
|
|
293
|
+
const mid = (cols - 1) / 2;
|
|
294
|
+
const order = Array.from({ length: cols }, (_, c) => c).sort((a, b) => Math.abs(a - mid) - Math.abs(b - mid));
|
|
295
|
+
return { rows, cols, line, pop: s.rules.popOut, cells, heights, windows: windowsOf(rows, cols, line), centre: Int8Array.from({ length: cols }, (_, c) => Math.round(mid - Math.abs(c - mid))), order, nodes: 0, limit: Infinity };
|
|
296
|
+
}
|
|
297
|
+
/** What `k` of one colour in an otherwise empty run is worth. */
|
|
298
|
+
const RUN_WORTH = [0, 1, 6, 40, 240, 1400];
|
|
299
|
+
/** The rack as `me` (1 or 2) sees it: what I'm building less what they are. */
|
|
300
|
+
function evaluate(b, me) {
|
|
301
|
+
const { cells, windows, line } = b;
|
|
302
|
+
let total = 0;
|
|
303
|
+
for (let i = 0; i < windows.length; i += line) {
|
|
304
|
+
let mine = 0;
|
|
305
|
+
let theirs = 0;
|
|
306
|
+
for (let k = 0; k < line; k++) {
|
|
307
|
+
const m = cells[windows[i + k]];
|
|
308
|
+
if (m === me)
|
|
309
|
+
mine++;
|
|
310
|
+
else if (m !== 0)
|
|
311
|
+
theirs++;
|
|
312
|
+
}
|
|
313
|
+
if (theirs === 0)
|
|
314
|
+
total += RUN_WORTH[mine];
|
|
315
|
+
else if (mine === 0)
|
|
316
|
+
total -= RUN_WORTH[theirs];
|
|
317
|
+
}
|
|
318
|
+
for (let c = 0; c < b.cols; c++) {
|
|
319
|
+
if (!b.centre[c])
|
|
320
|
+
continue;
|
|
321
|
+
for (let r = b.rows - b.heights[c]; r < b.rows; r++)
|
|
322
|
+
total += cells[r * b.cols + c] === me ? b.centre[c] : -b.centre[c];
|
|
323
|
+
}
|
|
324
|
+
return total;
|
|
325
|
+
}
|
|
326
|
+
/** Whether the disc at `cell` is part of a line of `who`'s. */
|
|
327
|
+
function lined(b, cell, who) {
|
|
328
|
+
const { rows, cols, line, cells } = b;
|
|
329
|
+
const r0 = Math.floor(cell / cols);
|
|
330
|
+
const c0 = cell % cols;
|
|
331
|
+
for (let d = 0; d < 4; d++) {
|
|
332
|
+
const dr = d === 0 ? 0 : 1;
|
|
333
|
+
const dc = d === 0 ? 1 : d === 1 ? 0 : d === 2 ? 1 : -1;
|
|
334
|
+
let n = 1;
|
|
335
|
+
for (let r = r0 + dr, c = c0 + dc; r < rows && c >= 0 && c < cols && cells[r * cols + c] === who; r += dr, c += dc)
|
|
336
|
+
n++;
|
|
337
|
+
for (let r = r0 - dr, c = c0 - dc; r >= 0 && c >= 0 && c < cols && cells[r * cols + c] === who; r -= dr, c -= dc)
|
|
338
|
+
n++;
|
|
339
|
+
if (n >= line)
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
/** A move in the search: a column to drop into, or `cols + column` to pop out of. */
|
|
345
|
+
function movesOf(b, me, out) {
|
|
346
|
+
out.length = 0;
|
|
347
|
+
for (const c of b.order)
|
|
348
|
+
if (b.heights[c] < b.rows)
|
|
349
|
+
out.push(c);
|
|
350
|
+
if (b.pop)
|
|
351
|
+
for (const c of b.order)
|
|
352
|
+
if (b.heights[c] > 0 && b.cells[(b.rows - 1) * b.cols + c] === me)
|
|
353
|
+
out.push(b.cols + c);
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
/** Make a move; says who it won the game for (1 or 2), or 0. */
|
|
357
|
+
function make(b, move, me) {
|
|
358
|
+
const { rows, cols, cells } = b;
|
|
359
|
+
if (move < cols) {
|
|
360
|
+
const cell = (rows - 1 - b.heights[move]) * cols + move;
|
|
361
|
+
cells[cell] = me;
|
|
362
|
+
b.heights[move]++;
|
|
363
|
+
return lined(b, cell, me) ? me : 0;
|
|
364
|
+
}
|
|
365
|
+
const c = move - cols;
|
|
366
|
+
for (let r = rows - 1; r > 0; r--)
|
|
367
|
+
cells[r * cols + c] = cells[(r - 1) * cols + c];
|
|
368
|
+
cells[c] = 0;
|
|
369
|
+
b.heights[c]--;
|
|
370
|
+
// Both colours may have a line now: whoever popped is looked at first.
|
|
371
|
+
for (const side of [me, 3 - me])
|
|
372
|
+
for (let r = rows - b.heights[c]; r < rows; r++)
|
|
373
|
+
if (cells[r * cols + c] === side && lined(b, r * cols + c, side))
|
|
374
|
+
return side;
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
function unmake(b, move, me) {
|
|
378
|
+
const { rows, cols, cells } = b;
|
|
379
|
+
if (move < cols) {
|
|
380
|
+
b.heights[move]--;
|
|
381
|
+
cells[(rows - 1 - b.heights[move]) * cols + move] = 0;
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const c = move - cols;
|
|
385
|
+
for (let r = 0; r < rows - 1; r++)
|
|
386
|
+
cells[r * cols + c] = cells[(r + 1) * cols + c];
|
|
387
|
+
cells[(rows - 1) * cols + c] = me;
|
|
388
|
+
b.heights[c]++;
|
|
389
|
+
}
|
|
390
|
+
/** The value of the rack for `me`, who is about to move, looking `depth` moves ahead. */
|
|
391
|
+
function search(b, me, depth, alpha, beta, ply) {
|
|
392
|
+
const moves = movesOf(b, me, []);
|
|
393
|
+
// Nowhere to go: the rack is full, a draw.
|
|
394
|
+
let best = moves.length ? -Infinity : 0;
|
|
395
|
+
for (const move of moves) {
|
|
396
|
+
if (++b.nodes > b.limit)
|
|
397
|
+
return 0;
|
|
398
|
+
const winner = make(b, move, me);
|
|
399
|
+
// Sooner wins are better than later ones, and later losses better than sooner ones.
|
|
400
|
+
const value = winner === me ? WIN - ply : winner ? ply - WIN : depth <= 1 ? evaluate(b, me) : -search(b, 3 - me, depth - 1, -beta, -alpha, ply + 1);
|
|
401
|
+
unmake(b, move, me);
|
|
402
|
+
if (value > best)
|
|
403
|
+
best = value;
|
|
404
|
+
if (best > alpha)
|
|
405
|
+
alpha = best;
|
|
406
|
+
if (alpha >= beta)
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
return best;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The best move for `seat`: looks one move deeper each time round, up to `depth` moves or until
|
|
413
|
+
* `budget` positions have been looked at (the last, unfinished look is thrown away).
|
|
414
|
+
*/
|
|
415
|
+
export function bestDropFourMove(s, seat, depth, budget, rand = Math.random) {
|
|
416
|
+
const b = rackOf(s);
|
|
417
|
+
const me = seat + 1;
|
|
418
|
+
// Columns the same distance from the middle are tried in either order, so games differ.
|
|
419
|
+
if (rand() < 0.5)
|
|
420
|
+
b.order = b.order.map((c) => b.cols - 1 - c);
|
|
421
|
+
let moves = movesOf(b, me, []);
|
|
422
|
+
let found = { move: moves[0], value: 0, depth: 0, nodes: 0 };
|
|
423
|
+
b.limit = budget;
|
|
424
|
+
for (let d = 1; d <= depth; d++) {
|
|
425
|
+
let best = moves[0];
|
|
426
|
+
let bestValue = -Infinity;
|
|
427
|
+
for (const move of moves) {
|
|
428
|
+
b.nodes++;
|
|
429
|
+
const winner = make(b, move, me);
|
|
430
|
+
const value = winner === me ? WIN : winner ? -WIN : d <= 1 ? evaluate(b, me) : -search(b, 3 - me, d - 1, -Infinity, -bestValue, 1);
|
|
431
|
+
unmake(b, move, me);
|
|
432
|
+
if (b.nodes > b.limit)
|
|
433
|
+
break;
|
|
434
|
+
if (value > bestValue)
|
|
435
|
+
[best, bestValue] = [move, value];
|
|
436
|
+
}
|
|
437
|
+
// Out of positions part way through a look: keep what the last whole one found.
|
|
438
|
+
if (b.nodes > b.limit && d > 2)
|
|
439
|
+
break;
|
|
440
|
+
found = { move: best, value: bestValue, depth: d, nodes: b.nodes };
|
|
441
|
+
// Looking deeper won't change a game that's already decided, and the next look costs several times this one.
|
|
442
|
+
if (Math.abs(bestValue) >= WIN - 100 || b.nodes * 4 > budget)
|
|
443
|
+
break;
|
|
444
|
+
// Start the next look with the best move so far: the rest are cut off sooner.
|
|
445
|
+
moves = [best, ...moves.filter((m) => m !== best)];
|
|
446
|
+
}
|
|
447
|
+
return found;
|
|
448
|
+
}
|
|
449
|
+
/** Whether a disc of `seat`'s dropped into `col` now would make a line. */
|
|
450
|
+
function wouldWin(s, col, seat) {
|
|
451
|
+
const cell = landing(s, col);
|
|
452
|
+
return cell !== null && lineThrough(s.rules.rows, s.rules.cols, cell, s.rules.line, (c) => c === cell || s.cells[c] === seat) !== null;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* How hard a computer player thinks: [moves ahead, positions looked at]. Scoring a position costs
|
|
456
|
+
* more on a bigger rack (there are more runs to count), so the sharpest player's allowance shrinks
|
|
457
|
+
* with it, and its slowest move takes about as long on any rack: a few hundredths of a second.
|
|
458
|
+
*/
|
|
459
|
+
function effort(style, r) {
|
|
460
|
+
const skill = style.skill ?? 1;
|
|
461
|
+
return skill >= 0.9 ? [12, Math.round(6_000_000 / windowsOf(r.rows, r.cols, r.line).length)] : skill >= 0.5 ? [4, 8000] : [2, 2000];
|
|
462
|
+
}
|
|
463
|
+
function bot(s, seat, style, rand = Math.random) {
|
|
464
|
+
if (s.phase === "scored")
|
|
465
|
+
return s.ready[seat] ? null : { type: "next" };
|
|
466
|
+
if (s.phase !== "playing" || s.turn !== seat)
|
|
467
|
+
return null;
|
|
468
|
+
const open = openColumns(s);
|
|
469
|
+
if (open.length && careless(style, rand))
|
|
470
|
+
return { type: "drop", col: pickRandom(open, rand) };
|
|
471
|
+
const [depth, budget] = effort(style, s.rules);
|
|
472
|
+
const found = bestDropFourMove(s, seat, depth, budget, rand);
|
|
473
|
+
// Lost whatever it does (they have two ways to finish): stop one of them anyway. The other
|
|
474
|
+
// player may only have seen the one.
|
|
475
|
+
const move = found.value <= 2 - WIN ? (open.find((col) => wouldWin(s, col, other(seat))) ?? found.move) : found.move;
|
|
476
|
+
if (move < s.rules.cols)
|
|
477
|
+
return { type: "drop", col: move };
|
|
478
|
+
const from = bottom(s.rules, move - s.rules.cols);
|
|
479
|
+
return { type: "pop", from, to: from };
|
|
480
|
+
}
|
|
481
|
+
// ---- The game -----------------------------------------------------------------------------------
|
|
482
|
+
function normalize(rules) {
|
|
483
|
+
const given = (rules && typeof rules === "object" ? rules : {});
|
|
484
|
+
const base = PRESETS.find((p) => p.id === given.id) ?? classicDropFour;
|
|
485
|
+
const r = { ...base, ...given };
|
|
486
|
+
const within = (value, list, or) => Math.min(list.at(-1), Math.max(list[0], Math.round(Number(value)) || or));
|
|
487
|
+
return {
|
|
488
|
+
id: String(r.id),
|
|
489
|
+
name: String(r.name),
|
|
490
|
+
cols: within(r.cols, COLS, 7),
|
|
491
|
+
rows: within(r.rows, ROWS, 6),
|
|
492
|
+
line: within(r.line, [3, 6], 4),
|
|
493
|
+
popOut: !!r.popOut,
|
|
494
|
+
first: r.first === "seat1" || r.first === "seat2" ? r.first : "random",
|
|
495
|
+
games: [1, 3, 5, 7].includes(Number(r.games)) ? Number(r.games) : 1,
|
|
496
|
+
drinking: !!r.drinking,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
const rules = {
|
|
500
|
+
id: "dropfour",
|
|
501
|
+
name: "Drop Four",
|
|
502
|
+
emoji: "🔴",
|
|
503
|
+
blurb: "Drop your discs, four in a row wins",
|
|
504
|
+
tagline: "Drop a disc in a column and line up four before the other player does.",
|
|
505
|
+
seats: { min: 2, max: 2, default: 2 },
|
|
506
|
+
teams: false,
|
|
507
|
+
presets: PRESETS,
|
|
508
|
+
fields: [
|
|
509
|
+
{ key: "cols", label: "Columns", kind: "choice", options: COLS.map((n) => [n, String(n)]) },
|
|
510
|
+
{ key: "rows", label: "Rows", kind: "choice", options: ROWS.map((n) => [n, String(n)]) },
|
|
511
|
+
{ key: "line", label: "In a row to win", kind: "choice", options: [[3, "Three"], [4, "Four"], [5, "Five"], [6, "Six"]] },
|
|
512
|
+
{ key: "popOut", label: "Pop Out", hint: "On your turn you may pop one of your own discs out of the bottom row instead.", kind: "toggle" },
|
|
513
|
+
{ key: "first", label: "Who goes first", kind: "choice", options: [["random", "Luck of the draw"], ["seat1", "The first seat"], ["seat2", "The second seat"]] },
|
|
514
|
+
{ key: "games", label: "Games in a match", kind: "choice", options: [[1, "Just one"], [3, "Three"], [5, "Five"], [7, "Seven"]] },
|
|
515
|
+
drinkingField(`Adults only. A shot clock: ${SHOT_CLOCK_MS / 1000} seconds to drop your disc, or drink and lose your turn (water counts).`),
|
|
516
|
+
],
|
|
517
|
+
normalize,
|
|
518
|
+
validate(r) {
|
|
519
|
+
return r.line > r.cols && r.line > r.rows ? [`${r.line} in a row doesn't fit in a rack ${r.cols} across and ${r.rows} high.`] : [];
|
|
520
|
+
},
|
|
521
|
+
describe(r) {
|
|
522
|
+
return [
|
|
523
|
+
`Take turns dropping a disc into one of ${r.cols} columns. It falls to the lowest free place; a column holds ${r.rows}.`,
|
|
524
|
+
`${NUMBER_WORDS[r.line - 3]} in a row wins: across, up and down, or on a slant. More than that in a row wins too.`,
|
|
525
|
+
...(r.popOut
|
|
526
|
+
? [
|
|
527
|
+
"Pop Out: instead of dropping a disc, you may take one of your own out of the bottom row. Everything above it falls a place.",
|
|
528
|
+
`If a pop lines up ${NUMBER_WORDS[r.line - 3].toLowerCase()} for both players, whoever popped wins. A full rack isn't the end while someone can pop; a game that reaches ${moveLimit(r)} moves is a draw.`,
|
|
529
|
+
]
|
|
530
|
+
: ["If the rack fills up first, it's a draw."]),
|
|
531
|
+
r.first === "random" ? "Red goes first; who gets to be red is the luck of the draw." : `Red goes first, and the ${r.first === "seat1" ? "first" : "second"} seat is red.`,
|
|
532
|
+
...(r.games > 1 ? [`A match is ${r.games} games; most wins takes it. Players take turns going first.`] : []),
|
|
533
|
+
...(r.drinking
|
|
534
|
+
? [`🍺 Drinking mode: once the other player's disc lands you have ${SHOT_CLOCK_MS / 1000} seconds to drop yours. Too slow, and you take a drink and lose your turn. The first move of a game, and the move after a missed turn, have no clock. Computer players don't drink.`]
|
|
535
|
+
: []),
|
|
536
|
+
];
|
|
537
|
+
},
|
|
538
|
+
newGame: newDropFour,
|
|
539
|
+
apply,
|
|
540
|
+
view,
|
|
541
|
+
bot,
|
|
542
|
+
// A search: off the table's main thread (see TableGame.heavy).
|
|
543
|
+
heavy: (s, seat) => s.phase === "playing" && s.turn === seat,
|
|
544
|
+
// With a shot clock on, a computer player keeps up with it.
|
|
545
|
+
thinkingTime: (s) => (s.rules.drinking ? 900 : thinkFor(Math.min(openColumns(s).length, 6), s.moves === 0 ? 0.7 : 1)),
|
|
546
|
+
// The shot clock: a clock of the game's own, which the table keeps (see TableGame.deadline).
|
|
547
|
+
deadline(s) {
|
|
548
|
+
return s.phase === "playing" && s.clock ? { after: SHOT_CLOCK_MS, seat: s.turn, move: { type: "timeout" } } : null;
|
|
549
|
+
},
|
|
550
|
+
summary(s) {
|
|
551
|
+
const over = s.phase === "finished";
|
|
552
|
+
return {
|
|
553
|
+
game: "dropfour",
|
|
554
|
+
rules: s.rules.name,
|
|
555
|
+
phase: s.phase,
|
|
556
|
+
over,
|
|
557
|
+
hand: s.round,
|
|
558
|
+
scoreLine: `${s.wins[0]} to ${s.wins[1]}${s.draws ? ` · ${s.draws} drawn` : ""}${s.rules.drinking ? ` · 🍺 ${s.tally.drank[0]} and ${s.tally.drank[1]}` : ""}`,
|
|
559
|
+
order: over ? order(s) : null,
|
|
560
|
+
winners: over ? winners(s) : null,
|
|
561
|
+
teams: false,
|
|
562
|
+
seats: s.seats,
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
/** Drop Four, with resigning, draw offers and taking a move back (see offers.ts). */
|
|
567
|
+
export const dropFour = withOffers(rules, {
|
|
568
|
+
// Not against the shot clock: a drop taken back would be a drink taken back, and a turn had twice.
|
|
569
|
+
takeBack: (s) => !s.rules.drinking,
|
|
570
|
+
settle(s, result) {
|
|
571
|
+
s.clock = false;
|
|
572
|
+
settleMatch(s, result);
|
|
573
|
+
},
|
|
574
|
+
// What each side is building, as the computer player weighs it.
|
|
575
|
+
standing: (s, seat) => evaluate(rackOf(s), seat + 1),
|
|
576
|
+
});
|
|
577
|
+
//# sourceMappingURL=dropfour.js.map
|