@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
package/dist/minigolf.js
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
// Mini Golf, for one to six players: the first game on the physics core and on the table-sports
|
|
2
|
+
// kit. Putt the ball into the cup in as few strokes as you can, hole after hole; the lowest total
|
|
3
|
+
// wins.
|
|
4
|
+
//
|
|
5
|
+
// Each player has a ball of their own colour. Whether balls touch depends on how the table plays.
|
|
6
|
+
//
|
|
7
|
+
// *In turn*, every ball that is out on the hole is on the table for every shot, and balls knock
|
|
8
|
+
// into each other as they would on a real green. What a family would expect then happens:
|
|
9
|
+
// - a ball that is knocked stays where it comes to rest, and its owner plays it from there;
|
|
10
|
+
// - a ball knocked into the cup is holed for its owner, at the strokes they had already taken
|
|
11
|
+
// (a house rule, `knockIn`: turned off, it is put back where it lay);
|
|
12
|
+
// - a ball knocked into the water or off the course goes back where it lay, at no cost to anyone:
|
|
13
|
+
// only the player who struck pays for where their own ball ends up.
|
|
14
|
+
// Everyone starts from the same tee, so a ball still lying on it (a timid putt, or one sent back
|
|
15
|
+
// from the water) is lifted while the next player tees off and put back afterwards: it is drawn
|
|
16
|
+
// faintly and can't be hit. The same goes for any ball lying on top of the one being struck, which
|
|
17
|
+
// only a ball put back can be. Nothing else is ever lifted. A table can also turn all of this off
|
|
18
|
+
// (`collide`), and balls then pass through each other as they do when everyone plays at once.
|
|
19
|
+
//
|
|
20
|
+
// *Everyone at once*, balls can't collide, because there is no one table: each player plays the
|
|
21
|
+
// hole at their own pace, and one may be holing out while another is still on the tee. Each shot
|
|
22
|
+
// is simulated with only the striker's ball, and the other balls are drawn as faint ghosts.
|
|
23
|
+
//
|
|
24
|
+
// Who putts when is a house rule. *In turn*: everyone tees off in order (the honour moves round
|
|
25
|
+
// one seat a hole), and after that whoever is furthest from the cup plays next, as on a real
|
|
26
|
+
// green. *Everyone at once*: no turns; each player plays the hole at their own pace and the table
|
|
27
|
+
// moves on when all of them have holed out or picked up.
|
|
28
|
+
//
|
|
29
|
+
// What is stored, and why (the replay decision). The state keeps where every ball lies as whole
|
|
30
|
+
// numbers and, for the last shot each seat took, the shot, the bodies it started from, where the
|
|
31
|
+
// physics came to rest and the physics version that played it. A view is made from that alone:
|
|
32
|
+
// nothing is simulated to draw a table, and what a browser animates is checked against the stored
|
|
33
|
+
// rest. The room on the server, though, rebuilds a table after a restart by replaying its move
|
|
34
|
+
// log through `apply`, which simulates each shot again. The physics core is bit-for-bit
|
|
35
|
+
// deterministic (test/minigolf.test.ts steps a recorded shot and compares), so under the same
|
|
36
|
+
// PHYSICS_VERSION and the same course data a replay is exact. A change to either can only alter
|
|
37
|
+
// rounds that are in progress across the deploy: finished rounds are never replayed for their
|
|
38
|
+
// results, and a replayed round stays a legal game (every logged shot is still a shot), it may
|
|
39
|
+
// just come out differently. Making history immune to that needs the room to keep a snapshot of
|
|
40
|
+
// the state beside the move log, which is on the roadmap for the rooms kit and not something an
|
|
41
|
+
// engine can do from inside `apply`.
|
|
42
|
+
import { PHYSICS_VERSION, simulate } from "@journey-to-code/physics";
|
|
43
|
+
import { BALL_RADIUS, courseOf, COURSES, holeWorld } from "./minigolfcourse.js";
|
|
44
|
+
import { angleTo, restOf } from "./sport.js";
|
|
45
|
+
import { careless, clone, GameError, markReady, nextHandChoices, nextRandom, rankSeats, thinkFor, who } from "./table.js";
|
|
46
|
+
export const nineHoles = { id: "minigolf-nine", name: "Nine holes, in turn", course: "back-garden", holes: 9, order: "turns", limit: 6, penalty: 1, collide: true, knockIn: true };
|
|
47
|
+
export const nineTogether = { id: "minigolf-together", name: "Nine holes, all at once", course: "back-garden", holes: 9, order: "together", limit: 6, penalty: 1, collide: true, knockIn: true };
|
|
48
|
+
export const quickThree = { id: "minigolf-quick", name: "Quick three, in turn", course: "back-garden", holes: 3, order: "turns", limit: 6, penalty: 1, collide: true, knockIn: true };
|
|
49
|
+
export const quickTogether = { id: "minigolf-quick-together", name: "Quick three, all at once", course: "back-garden", holes: 3, order: "together", limit: 6, penalty: 1, collide: true, knockIn: true };
|
|
50
|
+
const PRESETS = [nineHoles, nineTogether, quickThree, quickTogether];
|
|
51
|
+
/** The gentlest and hardest a ball may be struck. A putt of nothing would be a stroke that moves nothing. */
|
|
52
|
+
export const MIN_POWER = 20;
|
|
53
|
+
export const MAX_POWER = 1000;
|
|
54
|
+
const holesOf = (s) => courseOf(s.rules.course).holes.slice(0, s.rules.holes);
|
|
55
|
+
const holeOf = (s) => holesOf(s)[s.hole];
|
|
56
|
+
export const ballId = (seat) => `ball-${seat}`;
|
|
57
|
+
const seatOfBall = (id) => (id.startsWith("ball-") ? Number(id.slice(5)) : -1);
|
|
58
|
+
const cupOf = (h) => h.world.pockets[0];
|
|
59
|
+
/** Whether this table's balls knock into each other: in turn, unless the house says no. */
|
|
60
|
+
export const ballsCollide = (rules) => rules.order === "turns" && rules.collide;
|
|
61
|
+
/** A ball lying this close to the tee (centre to centre) is lifted while someone tees off. */
|
|
62
|
+
export const TEE_CLEAR = 3 * BALL_RADIUS;
|
|
63
|
+
/**
|
|
64
|
+
* The balls on the hole for a shot by `striker` (or, with nobody to play, as they lie), in seat
|
|
65
|
+
* order, and the seats whose balls are lifted out of the way for it.
|
|
66
|
+
*
|
|
67
|
+
* Where balls collide that is every ball that has left the tee, with the ones that are in or
|
|
68
|
+
* picked up marked as out of play, so the list is the same list for everyone at the table.
|
|
69
|
+
* Where they don't, it is the striker's ball alone.
|
|
70
|
+
*/
|
|
71
|
+
function onTheTable(s, striker) {
|
|
72
|
+
const mark = (seat) => {
|
|
73
|
+
const b = s.balls[seat];
|
|
74
|
+
return { id: ballId(seat), x: b.x, y: b.y, ...(b.done ? { pocketed: b.done === "holed" ? "cup" : "pickup" } : {}) };
|
|
75
|
+
};
|
|
76
|
+
if (!ballsCollide(s.rules))
|
|
77
|
+
return { balls: striker === null ? [] : [mark(striker)], lifted: [] };
|
|
78
|
+
const mine = striker === null ? null : s.balls[striker];
|
|
79
|
+
const balls = [];
|
|
80
|
+
const lifted = [];
|
|
81
|
+
s.balls.forEach((b, seat) => {
|
|
82
|
+
if (seat === striker || b.done)
|
|
83
|
+
return void balls.push(mark(seat));
|
|
84
|
+
// Not yet teed off: still in its owner's hand.
|
|
85
|
+
if (!b.teed)
|
|
86
|
+
return;
|
|
87
|
+
if (mine) {
|
|
88
|
+
const reach = mine.teed ? 2 * BALL_RADIUS : TEE_CLEAR;
|
|
89
|
+
if ((b.x - mine.x) * (b.x - mine.x) + (b.y - mine.y) * (b.y - mine.y) < reach * reach)
|
|
90
|
+
return void lifted.push(seat);
|
|
91
|
+
}
|
|
92
|
+
balls.push(mark(seat));
|
|
93
|
+
});
|
|
94
|
+
return { balls, lifted };
|
|
95
|
+
}
|
|
96
|
+
function startHole(s, hole) {
|
|
97
|
+
const h = holesOf(s)[hole];
|
|
98
|
+
s.hole = hole;
|
|
99
|
+
s.balls = Array.from({ length: s.seats }, () => ({ x: h.tee[0], y: h.tee[1], strokes: 0, teed: false, done: null }));
|
|
100
|
+
s.lasts = Array(s.seats).fill(null);
|
|
101
|
+
s.phase = "playing";
|
|
102
|
+
s.ready = Array(s.seats).fill(false);
|
|
103
|
+
s.narration = null;
|
|
104
|
+
s.turn = s.rules.order === "turns" ? nextUp(s) : null;
|
|
105
|
+
}
|
|
106
|
+
export function newMiniGolf(rules, seats, seed) {
|
|
107
|
+
const s = { game: "minigolf", rules, seats, rng: seed >>> 0, physics: PHYSICS_VERSION, shots: 0 };
|
|
108
|
+
s.card = Array.from({ length: seats }, () => Array(holesOf(s).length).fill(null));
|
|
109
|
+
s.first = Math.floor(nextRandom(s)() * seats);
|
|
110
|
+
startHole(s, 0);
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
/** Seats in the order they tee off on this hole. */
|
|
114
|
+
const teeOrder = (s) => Array.from({ length: s.seats }, (_, i) => (s.first + i) % s.seats);
|
|
115
|
+
/**
|
|
116
|
+
* In turn: who plays next. Anyone still on the tee goes first, in tee order; after that it is
|
|
117
|
+
* whoever lies furthest from the cup (the earlier in tee order if two are as far).
|
|
118
|
+
*/
|
|
119
|
+
function nextUp(s) {
|
|
120
|
+
const order = teeOrder(s).filter((seat) => !s.balls[seat].done);
|
|
121
|
+
const onTee = order.find((seat) => !s.balls[seat].teed);
|
|
122
|
+
if (onTee !== undefined)
|
|
123
|
+
return onTee;
|
|
124
|
+
const cup = cupOf(holeOf(s));
|
|
125
|
+
const far = (seat) => (s.balls[seat].x - cup.x) * (s.balls[seat].x - cup.x) + (s.balls[seat].y - cup.y) * (s.balls[seat].y - cup.y);
|
|
126
|
+
let best = null;
|
|
127
|
+
for (const seat of order)
|
|
128
|
+
if (best === null || far(seat) > far(best))
|
|
129
|
+
best = seat;
|
|
130
|
+
return best;
|
|
131
|
+
}
|
|
132
|
+
// ---- Scores -------------------------------------------------------------------------------------
|
|
133
|
+
/** A seat's strokes so far: finished holes and the one in play. */
|
|
134
|
+
const totalOf = (s, seat) => s.card[seat].reduce((n, v) => n + (v ?? 0), 0) + (s.card[seat][s.hole] === null ? s.balls[seat].strokes : 0);
|
|
135
|
+
/** Against par, over the holes a seat has finished. */
|
|
136
|
+
const toParOf = (s, seat) => holesOf(s).reduce((n, h, i) => (s.card[seat][i] === null ? n : n + s.card[seat][i] - h.par), 0);
|
|
137
|
+
const parText = (n) => (n === 0 ? "Level" : n > 0 ? `+${n}` : `−${-n}`);
|
|
138
|
+
const strokesText = (n) => `${n} stroke${n === 1 ? "" : "s"}`;
|
|
139
|
+
/** What golfers call a score on a hole. */
|
|
140
|
+
export function scoreName(strokes, par) {
|
|
141
|
+
if (strokes === 1)
|
|
142
|
+
return "a hole in one";
|
|
143
|
+
return { [-2]: "an eagle", [-1]: "a birdie", 0: "par", 1: "a bogey", 2: "a double bogey" }[strokes - par] ?? `${strokes}`;
|
|
144
|
+
}
|
|
145
|
+
const winnersOf = (s) => {
|
|
146
|
+
const totals = Array.from({ length: s.seats }, (_, seat) => totalOf(s, seat));
|
|
147
|
+
const best = Math.min(...totals);
|
|
148
|
+
return totals.flatMap((t, seat) => (t === best ? [seat] : []));
|
|
149
|
+
};
|
|
150
|
+
// ---- Moves --------------------------------------------------------------------------------------
|
|
151
|
+
function apply(state, seat, move) {
|
|
152
|
+
if (!Number.isInteger(seat) || seat < 0 || seat >= state.seats)
|
|
153
|
+
throw new GameError("That's not a seat at this table.");
|
|
154
|
+
if (move?.type === "next") {
|
|
155
|
+
if (state.phase !== "holed")
|
|
156
|
+
throw new GameError("The hole isn't finished yet.");
|
|
157
|
+
const s = clone(state);
|
|
158
|
+
if (markReady(s.ready, seat)) {
|
|
159
|
+
// The honour moves round a seat each hole.
|
|
160
|
+
s.first = (s.first + 1) % s.seats;
|
|
161
|
+
startHole(s, s.hole + 1);
|
|
162
|
+
}
|
|
163
|
+
return s;
|
|
164
|
+
}
|
|
165
|
+
if (move?.type !== "shot")
|
|
166
|
+
throw new GameError("That's not a move in Mini Golf.");
|
|
167
|
+
if (state.phase !== "playing")
|
|
168
|
+
throw new GameError(state.phase === "finished" ? "This round is over." : "Everyone is in. On to the next hole.");
|
|
169
|
+
const ball = state.balls[seat];
|
|
170
|
+
if (ball.done)
|
|
171
|
+
throw new GameError(ball.done === "holed" ? "You've holed out. Waiting for the others." : "You've picked up on this hole.");
|
|
172
|
+
if (state.turn !== null && state.turn !== seat)
|
|
173
|
+
throw new GameError("It's not your shot.");
|
|
174
|
+
if (move.body !== ballId(seat))
|
|
175
|
+
throw new GameError("That's not your ball.");
|
|
176
|
+
if (!Number.isInteger(move.angle) || move.angle < 0 || move.angle >= 36000)
|
|
177
|
+
throw new GameError("Aim somewhere first.");
|
|
178
|
+
if (!Number.isInteger(move.power) || move.power < MIN_POWER || move.power > MAX_POWER)
|
|
179
|
+
throw new GameError("That's not a putt: too soft or too hard.");
|
|
180
|
+
const s = clone(state);
|
|
181
|
+
const h = holeOf(s);
|
|
182
|
+
const mine = s.balls[seat];
|
|
183
|
+
const shot = { body: ballId(seat), angle: move.angle, power: move.power };
|
|
184
|
+
const before = holeWorld(h, onTheTable(s, seat).balls);
|
|
185
|
+
const played = simulate(before, shot);
|
|
186
|
+
const at = played.world.bodies.find((b) => b.id === shot.body);
|
|
187
|
+
s.shots += 1;
|
|
188
|
+
s.lasts[seat] = { n: s.shots, seat, shot, from: before.bodies, rest: restOf(played.world), ticks: played.ticks, physics: PHYSICS_VERSION };
|
|
189
|
+
mine.strokes += 1;
|
|
190
|
+
mine.teed = true;
|
|
191
|
+
if (at.pocketed !== undefined) {
|
|
192
|
+
mine.done = "holed";
|
|
193
|
+
mine.x = at.x;
|
|
194
|
+
mine.y = at.y;
|
|
195
|
+
const name = scoreName(mine.strokes, h.par);
|
|
196
|
+
s.narration = mine.strokes === 1 ? `${who(seat)} makes a hole in one!` : `${who(seat)} holes out in ${mine.strokes}${/^\d/.test(name) ? "" : `: ${name}`}`;
|
|
197
|
+
}
|
|
198
|
+
else if (at.out !== undefined) {
|
|
199
|
+
// Wet, or off the course: the ball goes back to where it was struck from, at a cost.
|
|
200
|
+
mine.strokes += s.rules.penalty;
|
|
201
|
+
const cost = s.rules.penalty ? `, plus ${strokesText(s.rules.penalty)}` : "";
|
|
202
|
+
s.narration = `${who(seat)} ${at.out === "bounds" ? "is off the course" : "finds the water"}: back where it was${cost}`;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
mine.x = at.x;
|
|
206
|
+
mine.y = at.y;
|
|
207
|
+
const lipped = played.events.some((e) => e.type === "skip" && e.body === shot.body);
|
|
208
|
+
s.narration = lipped ? `${who(seat)} runs it over the cup: too fast to drop` : `${who(seat)} putts`;
|
|
209
|
+
}
|
|
210
|
+
if (!mine.done && mine.strokes >= s.rules.limit) {
|
|
211
|
+
mine.done = "pickup";
|
|
212
|
+
s.narration = `${who(seat)} picks up after ${s.rules.limit} and writes down ${s.rules.limit + 1}`;
|
|
213
|
+
}
|
|
214
|
+
if (mine.done)
|
|
215
|
+
s.card[seat][s.hole] = mine.done === "holed" ? Math.min(mine.strokes, s.rules.limit + 1) : s.rules.limit + 1;
|
|
216
|
+
// The other balls on the table: wherever the shot left them, by the rules at the top of this file.
|
|
217
|
+
const said = [];
|
|
218
|
+
for (const body of played.world.bodies) {
|
|
219
|
+
const other = seatOfBall(body.id);
|
|
220
|
+
const ball = s.balls[other];
|
|
221
|
+
if (other === seat || !ball || ball.done)
|
|
222
|
+
continue;
|
|
223
|
+
if (body.pocketed !== undefined && s.rules.knockIn) {
|
|
224
|
+
ball.done = "holed";
|
|
225
|
+
ball.x = body.x;
|
|
226
|
+
ball.y = body.y;
|
|
227
|
+
s.card[other][s.hole] = Math.min(ball.strokes, s.rules.limit + 1);
|
|
228
|
+
said.push(`${who(other)}'s ball is knocked into the cup: in, in ${ball.strokes}`);
|
|
229
|
+
}
|
|
230
|
+
else if (body.pocketed !== undefined)
|
|
231
|
+
said.push(`${who(other)}'s ball is knocked into the cup, and goes back where it was`);
|
|
232
|
+
else if (body.out !== undefined)
|
|
233
|
+
said.push(`${who(other)}'s ball is knocked ${body.out === "bounds" ? "off the course" : "into the water"}, and goes back where it was`);
|
|
234
|
+
else if (body.x !== ball.x || body.y !== ball.y) {
|
|
235
|
+
ball.x = body.x;
|
|
236
|
+
ball.y = body.y;
|
|
237
|
+
said.push(`${who(other)}'s ball is knocked away`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (said.length)
|
|
241
|
+
s.narration = `${s.narration}. ${said.join(". ")}`;
|
|
242
|
+
if (s.balls.every((b) => b.done)) {
|
|
243
|
+
s.turn = null;
|
|
244
|
+
s.phase = s.hole + 1 >= holesOf(s).length ? "finished" : "holed";
|
|
245
|
+
}
|
|
246
|
+
else if (s.rules.order === "turns")
|
|
247
|
+
s.turn = nextUp(s);
|
|
248
|
+
return s;
|
|
249
|
+
}
|
|
250
|
+
// ---- What a seat sees ---------------------------------------------------------------------------
|
|
251
|
+
const TONES = ["white", "red", "blue", "gold", "purple", "orange"];
|
|
252
|
+
const lookOf = (seat) => ({ id: ballId(seat), owner: seat, kind: "ball", tone: TONES[seat % TONES.length], label: `${who(seat)}'s ball` });
|
|
253
|
+
function cardOf(s) {
|
|
254
|
+
const holes = holesOf(s);
|
|
255
|
+
return {
|
|
256
|
+
title: courseOf(s.rules.course).name,
|
|
257
|
+
columns: holes.map((h, i) => ({ label: String(i + 1), sub: String(h.par) })),
|
|
258
|
+
current: s.phase === "finished" ? null : s.hole,
|
|
259
|
+
rows: Array.from({ length: s.seats }, (_, seat) => ({
|
|
260
|
+
seat,
|
|
261
|
+
cells: holes.map((_, i) => (s.card[seat][i] !== null ? String(s.card[seat][i]) : i === s.hole && s.balls[seat].strokes ? String(s.balls[seat].strokes) : "")),
|
|
262
|
+
total: String(totalOf(s, seat)),
|
|
263
|
+
note: parText(toParOf(s, seat)),
|
|
264
|
+
})),
|
|
265
|
+
totalLabel: "Total",
|
|
266
|
+
noteLabel: "To par",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function resultOf(s) {
|
|
270
|
+
if (s.phase === "playing")
|
|
271
|
+
return null;
|
|
272
|
+
const seats = Array.from({ length: s.seats }, (_, i) => i);
|
|
273
|
+
const h = holeOf(s);
|
|
274
|
+
const total = { label: "Total", values: seats.map((i) => String(totalOf(s, i))), tone: "total" };
|
|
275
|
+
const par = { label: "To par", values: seats.map((i) => parText(toParOf(s, i))) };
|
|
276
|
+
if (s.phase === "finished")
|
|
277
|
+
return { key: "final", title: `${courseOf(s.rules.course).name}: the final card`, sides: seats.map((i) => [i]), rows: [{ label: `Hole ${s.hole + 1}`, values: seats.map((i) => String(s.card[i][s.hole])) }, total, par] };
|
|
278
|
+
return { key: `hole-${s.hole}`, title: `Hole ${s.hole + 1}: ${h.name} (par ${h.par})`, sides: seats.map((i) => [i]), rows: [{ label: "This hole", values: seats.map((i) => String(s.card[i][s.hole])), tone: "score" }, total, par] };
|
|
279
|
+
}
|
|
280
|
+
function overTitle(s, winners) {
|
|
281
|
+
const total = totalOf(s, winners[0]);
|
|
282
|
+
const par = toParOf(s, winners[0]);
|
|
283
|
+
const against = par === 0 ? "level par" : `${Math.abs(par)} ${par > 0 ? "over" : "under"} par`;
|
|
284
|
+
if (s.seats === 1)
|
|
285
|
+
return `Round finished: ${strokesText(total)}, ${against}`;
|
|
286
|
+
if (winners.length > 1)
|
|
287
|
+
return `It's a tie at ${total}!`;
|
|
288
|
+
return `${who(winners[0])} wins with ${total} (${against})!`;
|
|
289
|
+
}
|
|
290
|
+
function view(s, seat) {
|
|
291
|
+
const holes = holesOf(s);
|
|
292
|
+
const h = holes[s.hole];
|
|
293
|
+
const mine = s.balls[seat];
|
|
294
|
+
const playing = s.phase === "playing";
|
|
295
|
+
const together = s.rules.order === "together";
|
|
296
|
+
const canShoot = playing && !mine.done && (together || s.turn === seat);
|
|
297
|
+
// In turn, everyone watches every shot. All at once, a seat watches its own, and sees the
|
|
298
|
+
// other balls where they lie.
|
|
299
|
+
const last = together ? s.lasts[seat] : s.lasts.reduce((a, b) => (b && (!a || b.n > a.n) ? b : a), null);
|
|
300
|
+
const winners = s.phase === "finished" ? winnersOf(s) : [];
|
|
301
|
+
const onTee = playing && s.balls.every((b) => !b.teed);
|
|
302
|
+
// Where balls collide the table is one table: every seat is shown it as the player whose shot
|
|
303
|
+
// it is will find it (so all the seats' views agree, and so does someone watching). Where they
|
|
304
|
+
// don't, each seat has the hole to itself: its own ball, with a ball that is in or picked up
|
|
305
|
+
// marked as out of play, and everyone else's drawn as ghosts once they have left the tee.
|
|
306
|
+
const on = ballsCollide(s.rules) ? onTheTable(s, playing ? s.turn : null) : onTheTable(s, seat);
|
|
307
|
+
const ghost = (i) => ({ ...lookOf(i), x: s.balls[i].x, y: s.balls[i].y, r: BALL_RADIUS });
|
|
308
|
+
const ghosts = ballsCollide(s.rules) ? on.lifted.map(ghost) : s.balls.flatMap((b, i) => (i !== seat && b.teed && !b.done ? [ghost(i)] : []));
|
|
309
|
+
return {
|
|
310
|
+
game: "minigolf",
|
|
311
|
+
seats: s.seats,
|
|
312
|
+
me: seat,
|
|
313
|
+
teams: false,
|
|
314
|
+
phase: s.phase,
|
|
315
|
+
turn: playing && !together ? s.turn : null,
|
|
316
|
+
table: {
|
|
317
|
+
key: `${s.rules.course}/${h.id}`,
|
|
318
|
+
name: `Hole ${s.hole + 1}: ${h.name}`,
|
|
319
|
+
world: holeWorld(h, on.balls),
|
|
320
|
+
look: h.look,
|
|
321
|
+
bodies: [...h.fixtures, ...s.balls.map((_, i) => lookOf(i))],
|
|
322
|
+
ghosts,
|
|
323
|
+
},
|
|
324
|
+
shoot: canShoot ? { body: ballId(seat), minPower: MIN_POWER, maxPower: MAX_POWER } : null,
|
|
325
|
+
last,
|
|
326
|
+
card: cardOf(s),
|
|
327
|
+
choices: s.phase === "holed" ? nextHandChoices(s.ready, seat, false, "Next hole") : null,
|
|
328
|
+
players: s.balls.map((b, i) => ({
|
|
329
|
+
line: b.done === "holed" ? `In the cup in ${b.strokes}` : b.done === "pickup" ? `Picked up: ${s.rules.limit + 1}` : b.strokes ? strokesText(b.strokes) : "On the tee",
|
|
330
|
+
body: lookOf(i),
|
|
331
|
+
done: !!b.done,
|
|
332
|
+
badge: onTee && !together && s.seats > 1 && i === s.first ? "Tees off first" : undefined,
|
|
333
|
+
})),
|
|
334
|
+
info: [`Hole ${s.hole + 1} of ${holes.length}`, `Par ${h.par}`, ...(playing && !mine.done ? [`Stroke ${mine.strokes + 1} of ${s.rules.limit}`] : [])],
|
|
335
|
+
narration: s.narration ?? (playing ? h.tip : null),
|
|
336
|
+
scores: s.balls.map((_, i) => ({ seats: [i], value: totalOf(s, i) })),
|
|
337
|
+
result: resultOf(s),
|
|
338
|
+
over: s.phase === "finished" ? { winners, title: overTitle(s, winners) } : null,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const CELL = 200;
|
|
342
|
+
const FAR = 1e9;
|
|
343
|
+
const fields = new Map();
|
|
344
|
+
function inShape(s, x, y) {
|
|
345
|
+
if ("points" in s) {
|
|
346
|
+
let inside = false;
|
|
347
|
+
const pts = s.points;
|
|
348
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
349
|
+
const [xi, yi] = pts[i];
|
|
350
|
+
const [xj, yj] = pts[j];
|
|
351
|
+
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)
|
|
352
|
+
inside = !inside;
|
|
353
|
+
}
|
|
354
|
+
return inside;
|
|
355
|
+
}
|
|
356
|
+
return (x - s.x) * (x - s.x) + (y - s.y) * (y - s.y) < s.r * s.r;
|
|
357
|
+
}
|
|
358
|
+
/** The square of the distance from a point to a straight piece of wall. */
|
|
359
|
+
function toSegment(x, y, a, b) {
|
|
360
|
+
const ex = b[0] - a[0];
|
|
361
|
+
const ey = b[1] - a[1];
|
|
362
|
+
const t = Math.max(0, Math.min(1, ((x - a[0]) * ex + (y - a[1]) * ey) / (ex * ex + ey * ey)));
|
|
363
|
+
const dx = x - a[0] - t * ex;
|
|
364
|
+
const dy = y - a[1] - t * ey;
|
|
365
|
+
return dx * dx + dy * dy;
|
|
366
|
+
}
|
|
367
|
+
/** How far each spot on a hole is from the cup by the shortest way a ball could roll there. */
|
|
368
|
+
export function greenDistances(h) {
|
|
369
|
+
const known = fields.get(h.id);
|
|
370
|
+
if (known)
|
|
371
|
+
return known;
|
|
372
|
+
const f = h.look.frame;
|
|
373
|
+
const cols = Math.ceil(f.w / CELL);
|
|
374
|
+
const rows = Math.ceil(f.h / CELL);
|
|
375
|
+
const dist = new Float64Array(cols * rows).fill(Infinity);
|
|
376
|
+
// What a step onto each cell costs: 0 where a ball can't be (against a wall, in the water),
|
|
377
|
+
// more through sand than over grass.
|
|
378
|
+
const cost = new Float64Array(cols * rows);
|
|
379
|
+
const clear = (BALL_RADIUS - 40) * (BALL_RADIUS - 40);
|
|
380
|
+
const piecesOf = (oneWay) => h.world.walls.filter((w) => (w.oneWay === true) === oneWay).flatMap((w) => w.points.slice(0, w.closed ? w.points.length : -1).map((p, i) => [p, w.points[(i + 1) % w.points.length]]));
|
|
381
|
+
const pieces = piecesOf(false);
|
|
382
|
+
const ledges = piecesOf(true);
|
|
383
|
+
const at = (cell) => [f.x + ((cell % cols) + 0.5) * CELL, f.y + (Math.floor(cell / cols) + 0.5) * CELL];
|
|
384
|
+
for (let r = 0; r < rows; r++) {
|
|
385
|
+
for (let c = 0; c < cols; c++) {
|
|
386
|
+
const x = f.x + (c + 0.5) * CELL;
|
|
387
|
+
const y = f.y + (r + 0.5) * CELL;
|
|
388
|
+
if (pieces.some(([a, b]) => toSegment(x, y, a, b) < clear))
|
|
389
|
+
continue;
|
|
390
|
+
if (h.world.bodies.some((b) => (x - b.x) * (x - b.x) + (y - b.y) * (y - b.y) < (b.r + BALL_RADIUS - 40) * (b.r + BALL_RADIUS - 40)))
|
|
391
|
+
continue;
|
|
392
|
+
if (h.world.regions.some((g) => g.out && inShape(g.shape, x, y)))
|
|
393
|
+
continue;
|
|
394
|
+
cost[r * cols + c] = h.world.regions.some((g) => g.friction !== undefined && g.friction > h.world.friction && inShape(g.shape, x, y)) ? 3 : 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Dijkstra outwards from the cup, on a small heap of [distance, cell].
|
|
398
|
+
const heap = [];
|
|
399
|
+
const push = (d, cell) => {
|
|
400
|
+
heap.push([d, cell]);
|
|
401
|
+
for (let i = heap.length - 1; i > 0;) {
|
|
402
|
+
const up = (i - 1) >> 1;
|
|
403
|
+
if (heap[up][0] <= heap[i][0])
|
|
404
|
+
break;
|
|
405
|
+
[heap[up], heap[i]] = [heap[i], heap[up]];
|
|
406
|
+
i = up;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
const pop = () => {
|
|
410
|
+
const top = heap[0];
|
|
411
|
+
const end = heap.pop();
|
|
412
|
+
if (heap.length) {
|
|
413
|
+
heap[0] = end;
|
|
414
|
+
for (let i = 0;;) {
|
|
415
|
+
const l = 2 * i + 1;
|
|
416
|
+
const r = l + 1;
|
|
417
|
+
let low = i;
|
|
418
|
+
if (l < heap.length && heap[l][0] < heap[low][0])
|
|
419
|
+
low = l;
|
|
420
|
+
if (r < heap.length && heap[r][0] < heap[low][0])
|
|
421
|
+
low = r;
|
|
422
|
+
if (low === i)
|
|
423
|
+
break;
|
|
424
|
+
[heap[low], heap[i]] = [heap[i], heap[low]];
|
|
425
|
+
i = low;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return top;
|
|
429
|
+
};
|
|
430
|
+
const cup = cupOf(h);
|
|
431
|
+
const cellAt = (x, y) => Math.floor((y - f.y) / CELL) * cols + Math.floor((x - f.x) / CELL);
|
|
432
|
+
const start = cellAt(cup.x, cup.y);
|
|
433
|
+
dist[start] = 0;
|
|
434
|
+
push(0, start);
|
|
435
|
+
const tunnels = (h.world.tunnels ?? []).map((t) => ({ mouth: cellAt(t.x, t.y), out: cellAt(t.to[0], t.to[1]) }));
|
|
436
|
+
while (heap.length) {
|
|
437
|
+
const [d, cell] = pop();
|
|
438
|
+
if (d > dist[cell])
|
|
439
|
+
continue;
|
|
440
|
+
// A tunnel's mouth is a step away from where it comes out (a mouth in a rock face is in a
|
|
441
|
+
// cell no ball can rest in, which is fine: the way to it is found from there).
|
|
442
|
+
for (const t of tunnels) {
|
|
443
|
+
if (t.out === cell && d + CELL < dist[t.mouth]) {
|
|
444
|
+
dist[t.mouth] = d + CELL;
|
|
445
|
+
push(d + CELL, t.mouth);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const r = Math.floor(cell / cols);
|
|
449
|
+
const c = cell % cols;
|
|
450
|
+
for (let dr = -1; dr <= 1; dr++) {
|
|
451
|
+
for (let dc = -1; dc <= 1; dc++) {
|
|
452
|
+
if ((!dr && !dc) || r + dr < 0 || r + dr >= rows || c + dc < 0 || c + dc >= cols)
|
|
453
|
+
continue;
|
|
454
|
+
const next = (r + dr) * cols + c + dc;
|
|
455
|
+
if (!cost[next])
|
|
456
|
+
continue;
|
|
457
|
+
// The search runs backwards, from the cup out: a ball would roll from `next` to here, and
|
|
458
|
+
// it can't if that is up a ledge.
|
|
459
|
+
if (ledges.length && upLedge(ledges, at(next), at(cell)))
|
|
460
|
+
continue;
|
|
461
|
+
const nd = d + (dr && dc ? 1.4142 : 1) * CELL * cost[next];
|
|
462
|
+
if (nd < dist[next]) {
|
|
463
|
+
dist[next] = nd;
|
|
464
|
+
push(nd, next);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const field = { x: f.x, y: f.y, cols, rows, dist, ledges };
|
|
470
|
+
fields.set(h.id, field);
|
|
471
|
+
return field;
|
|
472
|
+
}
|
|
473
|
+
/** Whether going straight from one point to another crosses a ledge from its shut side. */
|
|
474
|
+
function upLedge(ledges, from, to) {
|
|
475
|
+
for (const [a, b] of ledges) {
|
|
476
|
+
const ex = b[0] - a[0];
|
|
477
|
+
const ey = b[1] - a[1];
|
|
478
|
+
// Which side of the ledge's line each end is on: positive is its right, the shut side.
|
|
479
|
+
const s1 = (from[0] - a[0]) * -ey + (from[1] - a[1]) * ex;
|
|
480
|
+
const s2 = (to[0] - a[0]) * -ey + (to[1] - a[1]) * ex;
|
|
481
|
+
if (s1 <= 0 || s2 > 0)
|
|
482
|
+
continue;
|
|
483
|
+
// And whether the crossing falls between the ledge's ends.
|
|
484
|
+
const t = s1 / (s1 - s2);
|
|
485
|
+
const along = (from[0] + (to[0] - from[0]) * t - a[0]) * ex + (from[1] + (to[1] - from[1]) * t - a[1]) * ey;
|
|
486
|
+
if (along >= 0 && along <= ex * ex + ey * ey)
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
/** How far a ball lying here is from the cup, along the green. */
|
|
492
|
+
export function distanceToCup(h, x, y) {
|
|
493
|
+
const f = greenDistances(h);
|
|
494
|
+
const c = Math.floor((x - f.x) / CELL);
|
|
495
|
+
const r = Math.floor((y - f.y) / CELL);
|
|
496
|
+
// A ball resting against a wall is in a cell no ball's centre is meant to reach: use its neighbours.
|
|
497
|
+
let best = FAR;
|
|
498
|
+
for (let dr = -1; dr <= 1; dr++) {
|
|
499
|
+
for (let dc = -1; dc <= 1; dc++) {
|
|
500
|
+
if (r + dr < 0 || r + dr >= f.rows || c + dc < 0 || c + dc >= f.cols)
|
|
501
|
+
continue;
|
|
502
|
+
// A ball lying under a ledge is not as near as the cell on top of it.
|
|
503
|
+
if ((dr || dc) && f.ledges.length && upLedge(f.ledges, [x, y], [f.x + (c + dc + 0.5) * CELL, f.y + (r + dr + 0.5) * CELL]))
|
|
504
|
+
continue;
|
|
505
|
+
const d = f.dist[(r + dr) * f.cols + c + dc] + (dr || dc ? CELL : 0);
|
|
506
|
+
if (d < best)
|
|
507
|
+
best = d;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return best;
|
|
511
|
+
}
|
|
512
|
+
const wrap = (angle) => ((Math.round(angle) % 36000) + 36000) % 36000;
|
|
513
|
+
const clampPower = (power) => Math.min(MAX_POWER, Math.max(MIN_POWER, Math.round(power)));
|
|
514
|
+
/** A putt tried in the head: lower is better; anything under zero is in the cup. */
|
|
515
|
+
const HOLED = -1e6;
|
|
516
|
+
function tryPutt(h, x, y, angle, power, penalty, others) {
|
|
517
|
+
const bodies = simulate(holeWorld(h, [{ id: "ball", x, y }, ...others]), { body: "ball", angle, power }).world.bodies;
|
|
518
|
+
const at = bodies.find((b) => b.id === "ball");
|
|
519
|
+
// Holing someone else's ball for them is worth avoiding, but not at the price of a worse lie.
|
|
520
|
+
const gift = others.length && bodies.some((b) => b.id !== "ball" && b.pocketed === "cup") ? 600 : 0;
|
|
521
|
+
if (at.pocketed !== undefined)
|
|
522
|
+
return HOLED + gift;
|
|
523
|
+
// In the water: as bad as staying put and throwing strokes away.
|
|
524
|
+
if (at.out !== undefined)
|
|
525
|
+
return distanceToCup(h, x, y) + 3000 * (1 + penalty) + gift;
|
|
526
|
+
return distanceToCup(h, at.x, at.y) + gift;
|
|
527
|
+
}
|
|
528
|
+
const COARSE_POWERS = [200, 320, 430, 530, 640, 800, 1000];
|
|
529
|
+
const thoughts = new Map();
|
|
530
|
+
/**
|
|
531
|
+
* The best putt from here that a search can find: what a player with a perfect stroke would hit.
|
|
532
|
+
* The work is bounded (at most 286 simulated putts) and remembered, since a table asks more than once.
|
|
533
|
+
* `others` are the balls lying on the hole that this one can knock into.
|
|
534
|
+
*/
|
|
535
|
+
export function bestPutt(h, x, y, penalty = 1, others = []) {
|
|
536
|
+
const key = `${h.id}/${x}/${y}/${penalty}${others.map((o) => `/${o.id}:${o.x}:${o.y}`).join("")}`;
|
|
537
|
+
const known = thoughts.get(key);
|
|
538
|
+
if (known)
|
|
539
|
+
return known;
|
|
540
|
+
let tried = 0;
|
|
541
|
+
const score = (angle, power) => {
|
|
542
|
+
tried++;
|
|
543
|
+
return tryPutt(h, x, y, angle, power, penalty, others);
|
|
544
|
+
};
|
|
545
|
+
const cup = cupOf(h);
|
|
546
|
+
const base = angleTo({ x, y }, cup);
|
|
547
|
+
// Every direction, straight at the cup included, at a spread of strengths.
|
|
548
|
+
let found = [];
|
|
549
|
+
for (let k = 0; k < 30; k++)
|
|
550
|
+
for (const power of COARSE_POWERS)
|
|
551
|
+
found.push({ angle: wrap(base + k * 1200), power, score: score(wrap(base + k * 1200), power) });
|
|
552
|
+
found.sort((a, b) => a.score - b.score);
|
|
553
|
+
// A closer look round the best few that aren't each other's neighbours.
|
|
554
|
+
const seeds = [];
|
|
555
|
+
for (const f of found) {
|
|
556
|
+
if (seeds.length >= 4)
|
|
557
|
+
break;
|
|
558
|
+
if (!seeds.some((s) => Math.abs(s.power - f.power) <= 120 && Math.min(Math.abs(s.angle - f.angle), 36000 - Math.abs(s.angle - f.angle)) <= 1200))
|
|
559
|
+
seeds.push(f);
|
|
560
|
+
}
|
|
561
|
+
for (const seed of seeds) {
|
|
562
|
+
for (const da of [-360, -180, 0, 180, 360]) {
|
|
563
|
+
for (const dp of [-50, 0, 50]) {
|
|
564
|
+
if (!da && !dp)
|
|
565
|
+
continue;
|
|
566
|
+
const angle = wrap(seed.angle + da);
|
|
567
|
+
const power = clampPower(seed.power + dp);
|
|
568
|
+
found.push({ angle, power, score: score(angle, power) });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
found.sort((a, b) => a.score - b.score);
|
|
573
|
+
// Several putts may drop, in a few families: straight at the cup, off this wall, off that one.
|
|
574
|
+
// From each family take the putt in the middle of its range of strengths (or, when nothing
|
|
575
|
+
// drops, take the three that stop nearest), gentlest families first.
|
|
576
|
+
const drops = found.filter((f) => f.score < 0).sort((a, b) => a.angle - b.angle);
|
|
577
|
+
const families = [];
|
|
578
|
+
for (const f of drops) {
|
|
579
|
+
const family = families.at(-1);
|
|
580
|
+
if (family && f.angle - family.at(-1).angle <= 500)
|
|
581
|
+
family.push(f);
|
|
582
|
+
else
|
|
583
|
+
families.push([f]);
|
|
584
|
+
}
|
|
585
|
+
const middles = families.map((family) => family.sort((a, b) => a.power - b.power)[family.length >> 1]).sort((a, b) => a.power - b.power);
|
|
586
|
+
const shortlist = middles.length ? middles.slice(0, 5) : found.slice(0, 3);
|
|
587
|
+
// Of those, prefer the one that still works when it's hit a little wrong.
|
|
588
|
+
let best = shortlist[0];
|
|
589
|
+
let bestSteady = Infinity;
|
|
590
|
+
for (const f of shortlist) {
|
|
591
|
+
let near = 0;
|
|
592
|
+
for (const [da, dp] of [[-120, -35], [120, -35], [-120, 35], [120, 35]])
|
|
593
|
+
near += Math.max(0, Math.min(score(wrap(f.angle + da), clampPower(f.power + dp)), 20000));
|
|
594
|
+
// All else equal, the gentler putt: the plain one a person would try, not a full-power ricochet.
|
|
595
|
+
const steady = (f.score < 0 ? -20000 : f.score) + near / 4 + f.power * 3;
|
|
596
|
+
if (steady < bestSteady)
|
|
597
|
+
[best, bestSteady] = [f, steady];
|
|
598
|
+
}
|
|
599
|
+
const putt = { ...best, tried };
|
|
600
|
+
if (thoughts.size > 400)
|
|
601
|
+
thoughts.clear();
|
|
602
|
+
thoughts.set(key, putt);
|
|
603
|
+
return putt;
|
|
604
|
+
}
|
|
605
|
+
/** The most a computer player's aim (hundredths of a degree) and strength (a share of it) can be off: at full skill, and how much more at none. */
|
|
606
|
+
export const AIM_SLIP = [300, 1100];
|
|
607
|
+
export const POWER_SLIP = [0.2, 0.45];
|
|
608
|
+
function bot(s, seat, style, rand = Math.random) {
|
|
609
|
+
if (s.phase === "holed")
|
|
610
|
+
return s.ready[seat] ? null : { type: "next" };
|
|
611
|
+
const ball = s.balls[seat];
|
|
612
|
+
if (s.phase !== "playing" || !ball || ball.done || (s.turn !== null && s.turn !== seat))
|
|
613
|
+
return null;
|
|
614
|
+
// The balls it could hit: the ones on the table for its shot that are still in play.
|
|
615
|
+
const others = onTheTable(s, seat).balls.filter((b) => b.id !== ballId(seat) && b.pocketed === undefined);
|
|
616
|
+
const ideal = bestPutt(holeOf(s), ball.x, ball.y, s.rules.penalty, others);
|
|
617
|
+
const skill = Math.min(1, Math.max(0, style.skill ?? 1));
|
|
618
|
+
const slip = careless(style, rand) ? 2 : 1;
|
|
619
|
+
// Three dice make a bell: mostly near the middle, now and then out at the edge.
|
|
620
|
+
const wobble = () => (rand() + rand() + rand() - 1.5) / 1.5;
|
|
621
|
+
// At full skill the aim is within 3° and the strength within 20%, and usually a third of that;
|
|
622
|
+
// at none, 14° and 65%. That is about what people manage with a thumb, which is the point: the
|
|
623
|
+
// search alone would hole nearly everything from the tee.
|
|
624
|
+
const angle = wrap(ideal.angle + wobble() * (AIM_SLIP[0] + (1 - skill) * AIM_SLIP[1]) * slip);
|
|
625
|
+
const power = clampPower(ideal.power * (1 + wobble() * (POWER_SLIP[0] + (1 - skill) * POWER_SLIP[1]) * slip));
|
|
626
|
+
return { type: "shot", body: ballId(seat), angle, power };
|
|
627
|
+
}
|
|
628
|
+
// ---- The game -----------------------------------------------------------------------------------
|
|
629
|
+
function normalize(rules) {
|
|
630
|
+
const given = (rules && typeof rules === "object" ? rules : {});
|
|
631
|
+
const base = PRESETS.find((p) => p.id === given.id) ?? nineHoles;
|
|
632
|
+
const r = { ...base, ...given };
|
|
633
|
+
const course = courseOf(r.course);
|
|
634
|
+
const whole = (v, min, max, or) => (Number.isInteger(Number(v)) && Number(v) >= min && Number(v) <= max ? Number(v) : or);
|
|
635
|
+
return {
|
|
636
|
+
id: String(r.id),
|
|
637
|
+
name: String(r.name),
|
|
638
|
+
course: course.id,
|
|
639
|
+
holes: whole(r.holes, 1, course.holes.length, course.holes.length),
|
|
640
|
+
order: r.order === "together" ? "together" : "turns",
|
|
641
|
+
limit: whole(r.limit, 3, 12, 6),
|
|
642
|
+
penalty: whole(r.penalty, 0, 3, 1),
|
|
643
|
+
collide: r.collide !== false,
|
|
644
|
+
knockIn: r.knockIn !== false,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
export const miniGolf = {
|
|
648
|
+
id: "minigolf",
|
|
649
|
+
name: "Mini Golf",
|
|
650
|
+
emoji: "⛳",
|
|
651
|
+
blurb: "Nine holes round the garden, in turn or all at once",
|
|
652
|
+
tagline: "Pull back, let go, and hope for a hole in one. Fewest strokes round the course wins.",
|
|
653
|
+
seats: { min: 1, max: 6, default: 2 },
|
|
654
|
+
teams: false,
|
|
655
|
+
presets: PRESETS,
|
|
656
|
+
fields: [
|
|
657
|
+
...(COURSES.length > 1 ? [{ key: "course", label: "Course", kind: "choice", options: COURSES.map((c) => [c.id, c.name]) }] : []),
|
|
658
|
+
{ key: "holes", label: "Holes to play", kind: "choice", options: [[3, "The first three (a quick game)"], [9, "All nine"]] },
|
|
659
|
+
{ key: "order", label: "Who putts when", kind: "choice", hint: "All at once, each player has the hole to themselves, so balls can't knock into each other.", options: [["turns", "In turn: furthest from the cup plays next"], ["together", "Everyone at once, at their own pace (balls pass through each other)"]] },
|
|
660
|
+
{ key: "collide", label: "Balls knock into each other", kind: "toggle", hint: "In turn only. A ball that is knocked is played from where it stops; knocked into the water, it goes back where it was at no cost." },
|
|
661
|
+
{ key: "knockIn", label: "A ball knocked into the cup counts", kind: "toggle", hint: "It is in, at the strokes its owner had taken. Off: it is put back where it was." },
|
|
662
|
+
{ key: "limit", label: "Most strokes on a hole", kind: "number", min: 3, max: 12, hint: "Not in by then? Pick up and write down one more." },
|
|
663
|
+
{ key: "penalty", label: "Strokes for a ball in the water", kind: "choice", options: [[0, "None: just play it again"], [1, "One"], [2, "Two"]] },
|
|
664
|
+
],
|
|
665
|
+
normalize,
|
|
666
|
+
validate(r) {
|
|
667
|
+
const course = COURSES.find((c) => c.id === r.course);
|
|
668
|
+
if (!course)
|
|
669
|
+
return ["That course isn't one we have."];
|
|
670
|
+
return r.holes < 1 || r.holes > course.holes.length ? [`${course.name} has ${course.holes.length} holes.`] : [];
|
|
671
|
+
},
|
|
672
|
+
describe(r) {
|
|
673
|
+
const course = courseOf(r.course);
|
|
674
|
+
return [
|
|
675
|
+
`${r.holes >= course.holes.length ? `All ${course.holes.length} holes` : `The first ${r.holes} holes`} of ${course.name}. Putt your ball into the cup in as few strokes as you can; the lowest total wins, and a tie is shared.`,
|
|
676
|
+
"To putt, pull back from the ball and let go: the further you pull, the harder it goes. A ball going too fast skips over the cup.",
|
|
677
|
+
r.order === "turns" ? "Everyone tees off in turn. After that, whoever is furthest from the cup plays next." : "Everyone plays at once, each at their own pace. The table moves on when all are in.",
|
|
678
|
+
r.order !== "turns"
|
|
679
|
+
? "Each player has the hole to themselves: the other balls are shown faintly and pass straight through yours. Nobody can knock yours away."
|
|
680
|
+
: !r.collide
|
|
681
|
+
? "Each player has a ball of their own, and balls pass through each other: nobody can knock yours away."
|
|
682
|
+
: `Balls knock into each other. A ball that is knocked is played from where it stops${r.knockIn ? "; knocked into the cup, it is in for its owner with no extra stroke" : "; knocked into the cup, it is put back where it was"}; knocked into the water, it goes back where it was at no cost. A ball still on the tee is lifted while the next player tees off.`,
|
|
683
|
+
`A ball in the water is played again from where it was struck${r.penalty ? `, with ${r.penalty === 1 ? "a penalty stroke" : `${r.penalty} penalty strokes`}` : ", with no penalty"}.`,
|
|
684
|
+
`Not in after ${r.limit} strokes? Pick up and write down ${r.limit + 1}.`,
|
|
685
|
+
];
|
|
686
|
+
},
|
|
687
|
+
newGame: newMiniGolf,
|
|
688
|
+
apply,
|
|
689
|
+
view,
|
|
690
|
+
bot,
|
|
691
|
+
// Hundreds of putts are simulated to choose one: off the table's main thread (see TableGame.heavy).
|
|
692
|
+
heavy: (s, seat) => s.phase === "playing" && !!s.balls[seat] && !s.balls[seat].done && (s.turn === null || s.turn === seat),
|
|
693
|
+
thinkingTime(s, seat) {
|
|
694
|
+
if (s.phase !== "playing")
|
|
695
|
+
return 500;
|
|
696
|
+
// In turn, a player watches the last putt stop before stepping up; all at once, only their own.
|
|
697
|
+
const last = s.rules.order === "turns" ? s.lasts.reduce((a, b) => (b && (!a || b.n > a.n) ? b : a), null) : s.lasts[seat];
|
|
698
|
+
const rolling = last ? Math.round((last.ticks * 1000) / holeOf(s).world.tickRate) : 0;
|
|
699
|
+
return rolling + thinkFor(3, s.balls[seat]?.teed ? 0.8 : 1.1);
|
|
700
|
+
},
|
|
701
|
+
warmUp() {
|
|
702
|
+
// A putt thought through from every tee: the distance maps get made, and the simulation gets hot.
|
|
703
|
+
for (const course of COURSES)
|
|
704
|
+
for (const h of course.holes)
|
|
705
|
+
bestPutt(h, h.tee[0], h.tee[1]);
|
|
706
|
+
},
|
|
707
|
+
summary(s) {
|
|
708
|
+
const over = s.phase === "finished";
|
|
709
|
+
const seats = Array.from({ length: s.seats }, (_, i) => i);
|
|
710
|
+
return {
|
|
711
|
+
game: "minigolf",
|
|
712
|
+
rules: s.rules.name,
|
|
713
|
+
phase: s.phase,
|
|
714
|
+
over,
|
|
715
|
+
hand: s.hole + 1,
|
|
716
|
+
scoreLine: seats.map((i) => totalOf(s, i)).join(" · ") + (s.seats === 1 ? " strokes" : ""),
|
|
717
|
+
order: over ? rankSeats(seats.map((i) => ({ seats: [i], score: totalOf(s, i) })), true) : null,
|
|
718
|
+
winners: over ? winnersOf(s) : null,
|
|
719
|
+
teams: false,
|
|
720
|
+
seats: s.seats,
|
|
721
|
+
};
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
//# sourceMappingURL=minigolf.js.map
|