@joystyk/launcher 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +245 -2
- package/dist/browser/daily-prefetch.d.ts +8 -0
- package/dist/browser/daily-prefetch.js +32 -0
- package/dist/browser/daily-prefetch.js.map +1 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/rooms.d.ts +2 -0
- package/dist/browser/rooms.js +49 -0
- package/dist/browser/rooms.js.map +1 -0
- package/dist/browser/services.d.ts +127 -0
- package/dist/browser/services.js +95 -0
- package/dist/browser/services.js.map +1 -0
- package/dist/browser/stage.d.ts +28 -0
- package/dist/browser/stage.js +1195 -0
- package/dist/browser/stage.js.map +1 -0
- package/dist/fixture/engine.d.ts +60 -0
- package/dist/fixture/engine.js +164 -0
- package/dist/fixture/engine.js.map +1 -0
- package/dist/fixture/index.d.ts +52 -0
- package/dist/fixture/index.js +63 -0
- package/dist/fixture/index.js.map +1 -0
- package/dist/fixture/module.d.ts +2 -0
- package/dist/fixture/module.js +135 -0
- package/dist/fixture/module.js.map +1 -0
- package/dist/fixture/probe.d.ts +10 -0
- package/dist/fixture/probe.js +19 -0
- package/dist/fixture/probe.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/launcher.d.ts +190 -0
- package/dist/launcher.js +4614 -0
- package/dist/launcher.js.map +1 -0
- package/dist/model.d.ts +431 -0
- package/dist/model.js +566 -0
- package/dist/model.js.map +1 -0
- package/dist/report-problem.d.ts +39 -0
- package/dist/report-problem.js +359 -0
- package/dist/report-problem.js.map +1 -0
- package/dist/report.d.ts +18 -0
- package/dist/report.js +32 -0
- package/dist/report.js.map +1 -0
- package/dist/room-drivers.d.ts +26 -0
- package/dist/room-drivers.js +92 -0
- package/dist/room-drivers.js.map +1 -0
- package/dist/safe-point.d.ts +33 -0
- package/dist/safe-point.js +44 -0
- package/dist/safe-point.js.map +1 -0
- package/dist/save-slots.d.ts +6 -0
- package/dist/save-slots.js +18 -0
- package/dist/save-slots.js.map +1 -0
- package/dist/theme.d.ts +35 -0
- package/dist/theme.js +111 -0
- package/dist/theme.js.map +1 -0
- package/package.json +67 -11
package/dist/model.js
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { ASSIST_KINDS, isGameId, isGameOptions, isJson, optionsForMode } from "@joystyk/contracts";
|
|
2
|
+
import { dailyWordStatsSchema } from "@joystyk/daily";
|
|
3
|
+
import { checkDisplayName } from "@joystyk/identity";
|
|
4
|
+
//#region src/model.ts
|
|
5
|
+
var MODES = [
|
|
6
|
+
"solo",
|
|
7
|
+
"ai",
|
|
8
|
+
"pass-and-play",
|
|
9
|
+
"p2p",
|
|
10
|
+
"server",
|
|
11
|
+
"daily"
|
|
12
|
+
];
|
|
13
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
var isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
15
|
+
var isMode = (value) => typeof value === "string" && MODES.includes(value);
|
|
16
|
+
var isLauncherGame = (value) => {
|
|
17
|
+
if (!isRecord(value) || !isGameId(value["id"]) || typeof value["name"] !== "string") return false;
|
|
18
|
+
const players = value["players"];
|
|
19
|
+
return Array.isArray(value["modes"]) && value["modes"].every(isMode) && isRecord(players) && Number.isInteger(players["min"]) && Number.isInteger(players["max"]) && (players["default"] === void 0 || Number.isInteger(players["default"])) && isStringArray(value["aiLevels"]) && (value["status"] === "available" || value["status"] === "coming-soon") && (value["aiContent"] === void 0 || typeof value["aiContent"] === "boolean") && (value["options"] === void 0 || isGameOptions(value["options"]));
|
|
20
|
+
};
|
|
21
|
+
/** The launcher's view of a manifest, for pages that have the manifest itself. */
|
|
22
|
+
var launcherGame = (manifest, status = "available") => ({
|
|
23
|
+
id: manifest.id,
|
|
24
|
+
name: manifest.name,
|
|
25
|
+
modes: [...manifest.modes],
|
|
26
|
+
players: manifest.players.default === void 0 ? {
|
|
27
|
+
min: manifest.players.min,
|
|
28
|
+
max: manifest.players.max
|
|
29
|
+
} : {
|
|
30
|
+
min: manifest.players.min,
|
|
31
|
+
max: manifest.players.max,
|
|
32
|
+
default: manifest.players.default
|
|
33
|
+
},
|
|
34
|
+
aiLevels: manifest.ai === null ? [] : [...manifest.ai.levels],
|
|
35
|
+
status,
|
|
36
|
+
aiContent: manifest.aiContent === true,
|
|
37
|
+
...manifest.options === void 0 ? {} : { options: manifest.options }
|
|
38
|
+
});
|
|
39
|
+
/** The AI-content notice (D-44; wording approved as D-52). */
|
|
40
|
+
var AI_NOTICE = {
|
|
41
|
+
title: "This game uses AI-made content",
|
|
42
|
+
body: "Some puzzles and clues here are written by an AI model. Each one passes automatic safety checks before it appears, but no person reads every one first, so now and then something may be odd or wrong. If you spot a problem, use Report a problem.",
|
|
43
|
+
scope: "Accepting unlocks all AI-content games on this device. You can change this anytime in Preferences."
|
|
44
|
+
};
|
|
45
|
+
/** True when an AI-content game must show the notice before it can start. */
|
|
46
|
+
var needsAiNotice = (game, preferences) => game.aiContent === true && preferences.aiContentAccepted !== true;
|
|
47
|
+
/**
|
|
48
|
+
* The access gate (monetization.md): where premium games and modes will ask for an
|
|
49
|
+
* entitlement in v2. Every game is free in v1, so it is always open.
|
|
50
|
+
*/
|
|
51
|
+
var accessGate = (access) => {
|
|
52
|
+
switch (access) {
|
|
53
|
+
case "free": return "open";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var MODE_LABELS = {
|
|
57
|
+
solo: "Solo",
|
|
58
|
+
ai: "Against the computer",
|
|
59
|
+
"pass-and-play": "Pass and play",
|
|
60
|
+
p2p: "Online, peer to peer",
|
|
61
|
+
server: "Online with friends",
|
|
62
|
+
daily: "Daily puzzle"
|
|
63
|
+
};
|
|
64
|
+
/** The modes the launcher runs on a local session. The daily puzzle is a local session on today's published config. */
|
|
65
|
+
var toLocalMode = (mode) => mode === "solo" || mode === "ai" || mode === "pass-and-play" || mode === "daily" ? mode : null;
|
|
66
|
+
var toStageMode = (mode) => mode === "server" ? "server" : toLocalMode(mode);
|
|
67
|
+
var ONLINE_CHECKING = { _tag: "Checking" };
|
|
68
|
+
var ONLINE_UNREACHABLE = { _tag: "Unreachable" };
|
|
69
|
+
/** The room server's answer, checked at the boundary; anything else (an error page, a proxy's 502 body) is undefined. */
|
|
70
|
+
var parseHostedGames = (value) => {
|
|
71
|
+
if (!isRecord(value)) return void 0;
|
|
72
|
+
const { games, protocol } = value;
|
|
73
|
+
if (!Array.isArray(games) || !games.every(isGameId) || !Number.isInteger(protocol) || protocol < 1) return void 0;
|
|
74
|
+
return {
|
|
75
|
+
_tag: "Hosted",
|
|
76
|
+
games: [...games],
|
|
77
|
+
protocol
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
/** True when the room server opens rooms for this game. */
|
|
81
|
+
var hostedOnline = (game, online) => online._tag === "Hosted" && online.games.includes(game.id);
|
|
82
|
+
/** Why Online with friends can't be picked yet: unknown while asking, coming soon (D-53) when not hosted. */
|
|
83
|
+
var onlineNote = (game, online) => {
|
|
84
|
+
switch (online._tag) {
|
|
85
|
+
case "Checking": return null;
|
|
86
|
+
case "Unreachable": return "Unavailable right now";
|
|
87
|
+
case "Hosted": return online.games.includes(game.id) ? null : "Coming soon";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* One option per mode in the manifest, in manifest order. Online with friends is playable only
|
|
92
|
+
* once the room server says it hosts the game; until it answers, and when it can't be reached,
|
|
93
|
+
* the local modes are unaffected.
|
|
94
|
+
*/
|
|
95
|
+
var modeOptions = (game, online = ONLINE_CHECKING) => game.modes.map((mode) => {
|
|
96
|
+
const run = toStageMode(mode);
|
|
97
|
+
const fits = run === "solo" || run === "daily" ? game.players.min <= 1 : run === "ai" ? game.aiLevels.length > 0 : run === "pass-and-play" || run === "server" ? game.players.max >= 2 : false;
|
|
98
|
+
const reachable = run !== "server" || hostedOnline(game, online);
|
|
99
|
+
const playable = game.status === "available" && run !== null && fits && reachable;
|
|
100
|
+
const note = run === null ? "Coming soon" : run === "server" && game.status === "available" && fits ? onlineNote(game, online) : null;
|
|
101
|
+
return {
|
|
102
|
+
mode,
|
|
103
|
+
label: MODE_LABELS[mode],
|
|
104
|
+
playable,
|
|
105
|
+
note
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
/**
|
|
109
|
+
* The player counts a mode allows: solo is one; local multiplayer takes two or more; a room
|
|
110
|
+
* sizes itself. In pass-and-play the count is people: a game with AI and more seats than
|
|
111
|
+
* people (Hearts, Spades) lets two or more share the device while the computer fills the rest.
|
|
112
|
+
*/
|
|
113
|
+
var playerCounts = (game, mode) => {
|
|
114
|
+
if (mode !== "ai" && mode !== "pass-and-play" || game.players.max === 1) return [1];
|
|
115
|
+
const counts = [];
|
|
116
|
+
const fewest = mode === "pass-and-play" && game.aiLevels.length > 0 ? 2 : Math.max(2, game.players.min);
|
|
117
|
+
for (let n = fewest; n <= game.players.max; n += 1) counts.push(n);
|
|
118
|
+
return counts;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* The count the picker starts at for a mode: against the computer, the game's declared
|
|
122
|
+
* default when the mode offers it (Shift 23 seats five, D-58); otherwise the fewest. Other
|
|
123
|
+
* modes count people, and a room sizes itself, so the default never applies to them.
|
|
124
|
+
*/
|
|
125
|
+
var startingCount = (game, mode) => {
|
|
126
|
+
const counts = playerCounts(game, mode);
|
|
127
|
+
const preset = game.players.default;
|
|
128
|
+
return mode === "ai" && preset !== void 0 && counts.includes(preset) ? preset : counts[0] ?? 1;
|
|
129
|
+
};
|
|
130
|
+
/** Pass-and-play with fewer people than the game's seats: the computer plays the rest. */
|
|
131
|
+
var computerSeatsFor = (game, setup) => setup.mode === "pass-and-play" ? Math.max(0, game.players.min - setup.players) : 0;
|
|
132
|
+
var initialSetup = (game, online = ONLINE_CHECKING) => {
|
|
133
|
+
const mode = modeOptions(game, online).find((option) => option.playable)?.mode ?? null;
|
|
134
|
+
return {
|
|
135
|
+
mode,
|
|
136
|
+
aiLevel: game.aiLevels[0] ?? null,
|
|
137
|
+
players: startingCount(game, mode),
|
|
138
|
+
names: [],
|
|
139
|
+
errors: [],
|
|
140
|
+
code: "",
|
|
141
|
+
watch: false,
|
|
142
|
+
codeError: null,
|
|
143
|
+
options: Object.fromEntries(optionsForMode(game.options ?? [], mode ?? "solo").map((o) => [o.key, o.default]))
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
/** Invite codes: six characters without look-alikes (no 0/O or 1/I), as the room server issues them. */
|
|
147
|
+
var ROOM_CODE = /^[A-HJ-NP-Z2-9]{6}$/;
|
|
148
|
+
var isRoomCode = (value) => typeof value === "string" && ROOM_CODE.test(value);
|
|
149
|
+
/**
|
|
150
|
+
* The room code in what a player typed or pasted: a code in any case, with spaces or dashes
|
|
151
|
+
* ("k7m-2qx"), or a whole invite link (`…?room=K7M2QX`). Null when there is none.
|
|
152
|
+
*/
|
|
153
|
+
var parseRoomCode = (input) => {
|
|
154
|
+
const trimmed = input.trim();
|
|
155
|
+
const code = (/[?&]room=([A-Za-z0-9-]+)/.exec(trimmed)?.[1] ?? trimmed).toUpperCase().replace(/[\s-]/g, "");
|
|
156
|
+
return isRoomCode(code) ? code : null;
|
|
157
|
+
};
|
|
158
|
+
/** "K7M 2QX": a code grouped for reading aloud and copying by eye. */
|
|
159
|
+
var spacedCode = (code) => `${code.slice(0, 3)} ${code.slice(3)}`;
|
|
160
|
+
var clockValue = (setting) => setting.kind === "off" ? "off" : setting.kind === "move" ? `move:${String(setting.ms)}` : `bank:${String(setting.initialMs)}:${String(setting.incrementMs)}`;
|
|
161
|
+
var parseClockValue = (value) => {
|
|
162
|
+
if (value === "off") return { kind: "off" };
|
|
163
|
+
const move = /^move:(\d{1,9})$/.exec(value ?? "");
|
|
164
|
+
if (move?.[1] !== void 0) return {
|
|
165
|
+
kind: "move",
|
|
166
|
+
ms: Number(move[1])
|
|
167
|
+
};
|
|
168
|
+
const bank = /^bank:(\d{1,9}):(\d{1,9})$/.exec(value ?? "");
|
|
169
|
+
if (bank?.[1] !== void 0 && bank[2] !== void 0) return {
|
|
170
|
+
kind: "bank",
|
|
171
|
+
initialMs: Number(bank[1]),
|
|
172
|
+
incrementMs: Number(bank[2])
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
var duration = (ms) => {
|
|
176
|
+
const s = Math.round(ms / 1e3);
|
|
177
|
+
if (s >= 60 && s % 60 === 0) return s === 60 ? "1 minute" : `${String(s / 60)} minutes`;
|
|
178
|
+
return s === 1 ? "1 second" : `${String(s)} seconds`;
|
|
179
|
+
};
|
|
180
|
+
/** "30 seconds a move", "5 minutes, plus 2 seconds a move", "Off". */
|
|
181
|
+
var clockLabel = (setting) => {
|
|
182
|
+
switch (setting.kind) {
|
|
183
|
+
case "off": return "Off";
|
|
184
|
+
case "move": return `${duration(setting.ms)} a move`;
|
|
185
|
+
case "bank": return setting.incrementMs > 0 ? `${duration(setting.initialMs)}, plus ${duration(setting.incrementMs)} a move` : `${duration(setting.initialMs)} each`;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* The lobby's clock choices for a manifest clock: its presets with the default marked, then
|
|
190
|
+
* Off, which private rooms always offer and never default to (D-42). None for a game without
|
|
191
|
+
* a clock.
|
|
192
|
+
*/
|
|
193
|
+
var clockOptions = (clock) => {
|
|
194
|
+
if (clock === null) return [];
|
|
195
|
+
const presets = clock.mode === "bank" ? clock.presets.map((p) => ({
|
|
196
|
+
kind: "bank",
|
|
197
|
+
initialMs: p.initialMs,
|
|
198
|
+
incrementMs: p.incrementMs
|
|
199
|
+
})) : clock.presets.map((s) => ({
|
|
200
|
+
kind: "move",
|
|
201
|
+
ms: s * 1e3
|
|
202
|
+
}));
|
|
203
|
+
const usual = clock.mode === "bank" ? {
|
|
204
|
+
kind: "bank",
|
|
205
|
+
initialMs: clock.default.initialMs,
|
|
206
|
+
incrementMs: clock.default.incrementMs
|
|
207
|
+
} : {
|
|
208
|
+
kind: "move",
|
|
209
|
+
ms: clock.default * 1e3
|
|
210
|
+
};
|
|
211
|
+
return [...(presets.some((p) => clockValue(p) === clockValue(usual)) ? presets : [usual, ...presets]).map((p) => ({
|
|
212
|
+
value: clockValue(p),
|
|
213
|
+
label: clockValue(p) === clockValue(usual) ? `${clockLabel(p)} (usual)` : clockLabel(p)
|
|
214
|
+
})), {
|
|
215
|
+
value: "off",
|
|
216
|
+
label: "Off: no clock"
|
|
217
|
+
}];
|
|
218
|
+
};
|
|
219
|
+
/** "Ana", "Ana and Ben", "Ana, Ben and Cy". */
|
|
220
|
+
var nameList = (names) => names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
|
|
221
|
+
/**
|
|
222
|
+
* What an offer's state means to the viewer (`me`, null for a spectator), in words. An open
|
|
223
|
+
* end offer says how long is left unless `countdown` is false (live-region announcements).
|
|
224
|
+
*/
|
|
225
|
+
var offerText = (offer, me, countdown = true) => {
|
|
226
|
+
const mine = offer.by === me;
|
|
227
|
+
const waiting = offer.waiting.length === 0 ? "" : ` Waiting for ${nameList(offer.waiting)}.`;
|
|
228
|
+
if (offer.kind === "rematch") switch (offer.status) {
|
|
229
|
+
case "open":
|
|
230
|
+
if (mine) return `You offered a rematch.${waiting}`;
|
|
231
|
+
if (me !== null && offer.accepted.includes(me)) return `You accepted ${offer.byName}'s rematch.${waiting}`;
|
|
232
|
+
return `${offer.byName} offers a rematch.`;
|
|
233
|
+
case "declined": return `${offer.declinedBy === me ? "You" : offer.declinedName ?? "Someone"} declined the rematch.`;
|
|
234
|
+
case "withdrawn": return mine ? "You withdrew your rematch offer." : `${offer.byName} withdrew the rematch offer.`;
|
|
235
|
+
}
|
|
236
|
+
switch (offer.status) {
|
|
237
|
+
case "open": {
|
|
238
|
+
const left = countdown && offer.seconds !== null ? ` It ends in ${formatElapsed(offer.seconds * 1e3)} unless someone declines.` : "";
|
|
239
|
+
if (mine) return `You asked to end the match.${left}`;
|
|
240
|
+
if (me !== null && offer.accepted.includes(me)) return `You agreed to end the match.${left}`;
|
|
241
|
+
return `${offer.byName} asks to end the match with no result.${left}`;
|
|
242
|
+
}
|
|
243
|
+
case "declined": return `${offer.declinedBy === me ? "You" : offer.declinedName ?? "Someone"} declined to end the match.`;
|
|
244
|
+
case "withdrawn": return mine ? "You withdrew your offer to end the match." : `${offer.byName} withdrew the offer to end the match.`;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* What to announce (politely) when the room's offer changed: a new offer, an answer, or
|
|
249
|
+
* nothing. `seats` names the players who accepted.
|
|
250
|
+
*/
|
|
251
|
+
var offerAnnouncement = (before, after, me, seats) => {
|
|
252
|
+
if (after === null) return null;
|
|
253
|
+
if (before === null || before.id !== after.id || before.status !== after.status) return offerText(after, me, false);
|
|
254
|
+
const names = after.accepted.filter((seat) => !before.accepted.includes(seat) && seat !== me).flatMap((seat) => seats.filter((s) => s.id === seat && s.controller === "human").map((s) => s.name));
|
|
255
|
+
return names.length === 0 ? null : `${nameList(names)} accepted.`;
|
|
256
|
+
};
|
|
257
|
+
/** Seats a player or the computer fills. */
|
|
258
|
+
var filledSeats = (room) => room.seats.filter((s) => s.claimed || s.controller === "ai");
|
|
259
|
+
/** Why the host can't start yet, or null when the match can start (the room checks again). */
|
|
260
|
+
var startProblem = (room) => {
|
|
261
|
+
const filled = filledSeats(room);
|
|
262
|
+
if (filled.length < room.minSeats) return `Waiting for ${room.minSeats - filled.length === 1 ? "another player" : `${String(room.minSeats - filled.length)} more players`}. Share the link, or add the computer.`;
|
|
263
|
+
const waiting = filled.filter((s) => s.controller === "human" && (!s.ready || !s.connected));
|
|
264
|
+
if (waiting.length > 0) return `Waiting for ${waiting.map((s) => s.name).join(" and ")} to be ready.`;
|
|
265
|
+
return null;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* A new mode keeps the AI level, and fits the player count to the mode: a count the player
|
|
269
|
+
* chose carries over when the new mode allows it. In a game that declares a default, a count
|
|
270
|
+
* still at the old mode's starting count was never chosen, so the new mode starts at its own.
|
|
271
|
+
*/
|
|
272
|
+
var pickMode = (game, setup, mode) => {
|
|
273
|
+
const counts = playerCounts(game, mode);
|
|
274
|
+
const untouched = game.players.default !== void 0 && setup.players === startingCount(game, setup.mode);
|
|
275
|
+
const players = counts.includes(setup.players) && !untouched ? setup.players : startingCount(game, mode);
|
|
276
|
+
return {
|
|
277
|
+
...setup,
|
|
278
|
+
mode,
|
|
279
|
+
players,
|
|
280
|
+
errors: []
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
/** The name a seat gets when its field is left empty. */
|
|
284
|
+
var defaultName = (seat) => `Player ${String(seat + 1)}`;
|
|
285
|
+
/** An empty name on a human seat means "the guest's own display name"; the stage fills it in. */
|
|
286
|
+
var GUEST_NAME = "";
|
|
287
|
+
/**
|
|
288
|
+
* The seats for a setup, numbered from 0, or a problem per pass-and-play name. Solo and
|
|
289
|
+
* vs-AI play seat the guest first; AI seats are named for the computer.
|
|
290
|
+
*/
|
|
291
|
+
var seatsFor = (game, setup) => {
|
|
292
|
+
switch (setup.mode) {
|
|
293
|
+
case "solo":
|
|
294
|
+
case "daily": return {
|
|
295
|
+
ok: true,
|
|
296
|
+
seats: [{
|
|
297
|
+
id: 0,
|
|
298
|
+
name: "",
|
|
299
|
+
controller: "human"
|
|
300
|
+
}]
|
|
301
|
+
};
|
|
302
|
+
case "ai": {
|
|
303
|
+
const level = setup.aiLevel ?? game.aiLevels[0] ?? "default";
|
|
304
|
+
const opponents = setup.players - 1;
|
|
305
|
+
const seats = [{
|
|
306
|
+
id: 0,
|
|
307
|
+
name: "",
|
|
308
|
+
controller: "human"
|
|
309
|
+
}];
|
|
310
|
+
for (let n = 1; n <= opponents; n += 1) seats.push({
|
|
311
|
+
id: n,
|
|
312
|
+
name: opponents === 1 ? "Computer" : `Computer ${String(n)}`,
|
|
313
|
+
controller: "ai",
|
|
314
|
+
aiLevel: level
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
seats
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
case "pass-and-play": {
|
|
322
|
+
const names = [];
|
|
323
|
+
const errors = [];
|
|
324
|
+
for (let seat = 0; seat < setup.players; seat += 1) {
|
|
325
|
+
const typed = setup.names[seat]?.trim() ?? "";
|
|
326
|
+
if (typed === "") {
|
|
327
|
+
names.push(defaultName(seat));
|
|
328
|
+
errors.push(null);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const checked = checkDisplayName(typed);
|
|
332
|
+
names.push(checked.ok ? checked.value : typed);
|
|
333
|
+
errors.push(checked.ok ? null : checked.error.message);
|
|
334
|
+
}
|
|
335
|
+
names.forEach((name, seat) => {
|
|
336
|
+
if (errors[seat] === null && names.findIndex((other) => other.toLowerCase() === name.toLowerCase()) !== seat) errors[seat] = "Each player needs a different name.";
|
|
337
|
+
});
|
|
338
|
+
if (errors.some((e) => e !== null)) return {
|
|
339
|
+
ok: false,
|
|
340
|
+
errors
|
|
341
|
+
};
|
|
342
|
+
const people = names.map((name, id) => ({
|
|
343
|
+
id,
|
|
344
|
+
name,
|
|
345
|
+
controller: "human"
|
|
346
|
+
}));
|
|
347
|
+
const level = setup.aiLevel ?? game.aiLevels[0] ?? "default";
|
|
348
|
+
const computers = computerSeatsFor(game, setup);
|
|
349
|
+
for (let n = 1; n <= computers; n += 1) people.push({
|
|
350
|
+
id: people.length,
|
|
351
|
+
name: computers === 1 ? "Computer" : `Computer ${String(n)}`,
|
|
352
|
+
controller: "ai",
|
|
353
|
+
aiLevel: level
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
seats: people
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
default: return {
|
|
361
|
+
ok: false,
|
|
362
|
+
errors: []
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
var isNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
367
|
+
var isStoredPreferences = (value) => isRecord(value) && [
|
|
368
|
+
"system",
|
|
369
|
+
"reduce",
|
|
370
|
+
"no-preference"
|
|
371
|
+
].includes(value["reducedMotion"]) && typeof value["sound"] === "boolean" && isNumber(value["volume"]) && typeof value["singleKeyShortcuts"] === "boolean" && [
|
|
372
|
+
"system",
|
|
373
|
+
"light",
|
|
374
|
+
"dark"
|
|
375
|
+
].includes(value["theme"]) && (value["locale"] === null || typeof value["locale"] === "string") && (value["aiContentAccepted"] === void 0 || typeof value["aiContentAccepted"] === "boolean");
|
|
376
|
+
var isSeat = (value) => isRecord(value) && Number.isInteger(value["id"]) && typeof value["name"] === "string" && (value["controller"] === "human" || value["controller"] === "ai");
|
|
377
|
+
var isOutcome = (value) => isRecord(value) && [
|
|
378
|
+
"won",
|
|
379
|
+
"lost",
|
|
380
|
+
"draw",
|
|
381
|
+
"completed",
|
|
382
|
+
"abandoned"
|
|
383
|
+
].includes(value["result"]) && Array.isArray(value["placements"]) && typeof value["summary"] === "string";
|
|
384
|
+
var isFrame = (value) => isRecord(value) && Array.isArray(value["seats"]) && value["seats"].every(isSeat) && (value["handoff"] === null || isRecord(value["handoff"]) && Number.isInteger(value["handoff"]["seat"]) && typeof value["handoff"]["name"] === "string") && (value["outcome"] === null || isOutcome(value["outcome"])) && typeof value["safePoint"] === "boolean" && (value["notice"] === null || typeof value["notice"] === "string");
|
|
385
|
+
var isClockSetting = (value) => isRecord(value) && [
|
|
386
|
+
"move",
|
|
387
|
+
"bank",
|
|
388
|
+
"off"
|
|
389
|
+
].includes(value["kind"]) && parseClockValue(clockValue(value)) !== void 0;
|
|
390
|
+
var isRoomSeat = (value) => isRecord(value) && Number.isInteger(value["id"]) && typeof value["name"] === "string" && (value["controller"] === "human" || value["controller"] === "ai") && (value["aiLevel"] === null || typeof value["aiLevel"] === "string") && typeof value["claimed"] === "boolean" && typeof value["ready"] === "boolean" && typeof value["connected"] === "boolean";
|
|
391
|
+
var isSeatOrNull = (value) => value === null || Number.isInteger(value);
|
|
392
|
+
var isOfferFrame = (value) => isRecord(value) && Number.isInteger(value["id"]) && (value["kind"] === "rematch" || value["kind"] === "end") && Number.isInteger(value["by"]) && typeof value["byName"] === "string" && [
|
|
393
|
+
"open",
|
|
394
|
+
"declined",
|
|
395
|
+
"withdrawn"
|
|
396
|
+
].includes(value["status"]) && Array.isArray(value["accepted"]) && value["accepted"].every((seat) => Number.isInteger(seat)) && isSeatOrNull(value["declinedBy"]) && (value["declinedName"] === null || typeof value["declinedName"] === "string") && isStringArray(value["waiting"]) && (value["seconds"] === null || isNumber(value["seconds"]));
|
|
397
|
+
var isRoomFrame = (value) => {
|
|
398
|
+
if (!isRecord(value)) return false;
|
|
399
|
+
const timer = value["timer"];
|
|
400
|
+
const options = value["clockOptions"];
|
|
401
|
+
return isRoomCode(value["code"]) && typeof value["link"] === "string" && [
|
|
402
|
+
"connecting",
|
|
403
|
+
"lobby",
|
|
404
|
+
"playing",
|
|
405
|
+
"finished",
|
|
406
|
+
"closed"
|
|
407
|
+
].includes(value["phase"]) && [
|
|
408
|
+
"connecting",
|
|
409
|
+
"online",
|
|
410
|
+
"reconnecting",
|
|
411
|
+
"closed"
|
|
412
|
+
].includes(value["connection"]) && isSeatOrNull(value["seat"]) && isSeatOrNull(value["host"]) && Array.isArray(value["seats"]) && value["seats"].every(isRoomSeat) && Number.isInteger(value["minSeats"]) && isClockSetting(value["clock"]) && Array.isArray(options) && options.every((o) => isRecord(o) && typeof o["value"] === "string" && typeof o["label"] === "string") && (value["aiLevel"] === null || typeof value["aiLevel"] === "string") && isStringArray(value["aiLevels"]) && Number.isInteger(value["spectators"]) && (timer === null || isRecord(timer) && Number.isInteger(timer["seat"]) && typeof timer["name"] === "string" && isNumber(timer["seconds"]) && typeof timer["paused"] === "boolean") && (value["notice"] === null || typeof value["notice"] === "string") && (value["noticeCode"] === null || typeof value["noticeCode"] === "string") && Number.isInteger(value["match"]) && (value["offer"] === null || isOfferFrame(value["offer"]));
|
|
413
|
+
};
|
|
414
|
+
var isCount = (value) => Number.isInteger(value) && value >= 0;
|
|
415
|
+
var isDailyFrame = (value) => {
|
|
416
|
+
if (!isRecord(value)) return false;
|
|
417
|
+
const earlier = value["earlier"];
|
|
418
|
+
return typeof value["date"] === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value["date"]) && typeof value["share"] === "string" && typeof value["shareWords"] === "string" && isCount(value["streak"]) && isCount(value["bestStreak"]) && (value["winStreak"] === null || isCount(value["winStreak"])) && (value["bestWinStreak"] === null || isCount(value["bestWinStreak"])) && typeof value["next"] === "string" && (value["statistics"] === void 0 || dailyWordStatsSchema.safeParse(value["statistics"]).success) && (earlier === null || isRecord(earlier) && [
|
|
419
|
+
"won",
|
|
420
|
+
"lost",
|
|
421
|
+
"draw",
|
|
422
|
+
"completed",
|
|
423
|
+
"abandoned"
|
|
424
|
+
].includes(earlier["result"]) && typeof earlier["summary"] === "string");
|
|
425
|
+
};
|
|
426
|
+
var savePart = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value);
|
|
427
|
+
var validSaveSummary = (value) => {
|
|
428
|
+
if (!isRecord(value) || !isNumber(value["savedAt"]) || !isNumber(value["elapsedMs"]) || !isStringArray(value["players"])) return false;
|
|
429
|
+
if (value["slot"] !== void 0 && !savePart(value["slot"])) return false;
|
|
430
|
+
const choices = value["choices"];
|
|
431
|
+
return choices === void 0 || Array.isArray(choices) && choices.length > 0 && choices.every((c) => isRecord(c) && savePart(c["slot"]) && typeof c["label"] === "string" && c["label"].length > 0 && c["label"].length <= 100 && isNumber(c["savedAt"]) && isNumber(c["elapsedMs"]) && isStringArray(c["players"])) && new Set(choices.map((c) => c.slot)).size === choices.length && choices.some((c) => c.slot === value["slot"]);
|
|
432
|
+
};
|
|
433
|
+
var saveSummarySchema = { "~standard": {
|
|
434
|
+
version: 1,
|
|
435
|
+
vendor: "joystyk",
|
|
436
|
+
validate: (raw) => validSaveSummary(raw) ? { value: raw } : { issues: [{ message: "Expected a validated saved-game offer." }] }
|
|
437
|
+
} };
|
|
438
|
+
var isSaveSummary = (raw) => {
|
|
439
|
+
const p = saveSummarySchema["~standard"].validate(raw);
|
|
440
|
+
return !(p instanceof Promise) && p.issues === void 0;
|
|
441
|
+
};
|
|
442
|
+
/** Checks a stage report at the boundary; anything unexpected is dropped. */
|
|
443
|
+
var parseStageOutput = (value) => {
|
|
444
|
+
if (!isRecord(value)) return void 0;
|
|
445
|
+
switch (value["_tag"]) {
|
|
446
|
+
case "Prepared": return (value["save"] === null || isSaveSummary(value["save"])) && isStoredPreferences(value["preferences"]) ? {
|
|
447
|
+
_tag: "Prepared",
|
|
448
|
+
save: value["save"],
|
|
449
|
+
preferences: value["preferences"]
|
|
450
|
+
} : void 0;
|
|
451
|
+
case "Preferences": return isStoredPreferences(value["preferences"]) ? {
|
|
452
|
+
_tag: "Preferences",
|
|
453
|
+
preferences: value["preferences"]
|
|
454
|
+
} : void 0;
|
|
455
|
+
case "Frame": return isFrame(value["frame"]) ? {
|
|
456
|
+
_tag: "Frame",
|
|
457
|
+
frame: value["frame"]
|
|
458
|
+
} : void 0;
|
|
459
|
+
case "ExitRequested": return { _tag: "ExitRequested" };
|
|
460
|
+
case "Rematch": return isJson(value["config"]) ? {
|
|
461
|
+
_tag: "Rematch",
|
|
462
|
+
config: value["config"]
|
|
463
|
+
} : void 0;
|
|
464
|
+
case "Room": return isRoomFrame(value["room"]) ? {
|
|
465
|
+
_tag: "Room",
|
|
466
|
+
room: value["room"]
|
|
467
|
+
} : void 0;
|
|
468
|
+
case "Daily": return isDailyFrame(value["daily"]) ? {
|
|
469
|
+
_tag: "Daily",
|
|
470
|
+
daily: value["daily"]
|
|
471
|
+
} : void 0;
|
|
472
|
+
case "Puzzle": return typeof value["puzzleId"] === "string" && /^[a-z0-9-]+(:[a-z0-9-]+)+$/.test(value["puzzleId"]) && value["puzzleId"].length <= 120 ? {
|
|
473
|
+
_tag: "Puzzle",
|
|
474
|
+
puzzleId: value["puzzleId"]
|
|
475
|
+
} : void 0;
|
|
476
|
+
case "Announce": return typeof value["message"] === "string" && (value["politeness"] === "polite" || value["politeness"] === "assertive") ? {
|
|
477
|
+
_tag: "Announce",
|
|
478
|
+
message: value["message"],
|
|
479
|
+
politeness: value["politeness"]
|
|
480
|
+
} : void 0;
|
|
481
|
+
case "Notice":
|
|
482
|
+
case "Failed": return typeof value["message"] === "string" ? {
|
|
483
|
+
_tag: value["_tag"],
|
|
484
|
+
message: value["message"]
|
|
485
|
+
} : void 0;
|
|
486
|
+
default: return;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
/** "0:42", "12:05", "1:02:03". */
|
|
490
|
+
var formatElapsed = (ms) => {
|
|
491
|
+
const total = Math.max(0, Math.round(ms / 1e3));
|
|
492
|
+
const hours = Math.floor(total / 3600);
|
|
493
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
494
|
+
const seconds = String(total % 60).padStart(2, "0");
|
|
495
|
+
return hours > 0 ? `${String(hours)}:${String(minutes).padStart(2, "0")}:${seconds}` : `${String(minutes)}:${seconds}`;
|
|
496
|
+
};
|
|
497
|
+
/** An ISO 8601 duration for `<time datetime>`: "PT1M5S". */
|
|
498
|
+
var isoDuration = (ms) => {
|
|
499
|
+
const total = Math.max(0, Math.round(ms / 1e3));
|
|
500
|
+
const hours = Math.floor(total / 3600);
|
|
501
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
502
|
+
const seconds = total % 60;
|
|
503
|
+
return `PT${hours > 0 ? `${String(hours)}H` : ""}${minutes > 0 ? `${String(minutes)}M` : ""}${String(seconds)}S`;
|
|
504
|
+
};
|
|
505
|
+
var ASSIST_WORDS = {
|
|
506
|
+
hint: "hints",
|
|
507
|
+
check: "checks",
|
|
508
|
+
reveal: "reveals",
|
|
509
|
+
shuffle: "shuffles"
|
|
510
|
+
};
|
|
511
|
+
/** The assistance line for an outcome, from what the runner recorded, or null when there was none. */
|
|
512
|
+
var assistLine = (outcome) => {
|
|
513
|
+
const undos = outcome.undosUsed ?? 0;
|
|
514
|
+
const kinds = new Set(outcome.assists ?? []);
|
|
515
|
+
if (undos > 0) kinds.add("undo");
|
|
516
|
+
const parts = ASSIST_KINDS.filter((kind) => kinds.has(kind)).map((kind) => kind === "undo" ? undos > 1 ? `${String(undos)} undos` : undos === 1 ? "1 undo" : "undo" : ASSIST_WORDS[kind]);
|
|
517
|
+
return parts.length === 0 ? null : `Assisted: ${parts.join(", ")}`;
|
|
518
|
+
};
|
|
519
|
+
var nameOf = (seats, seat) => seats.find((s) => s.id === seat)?.name ?? "Someone";
|
|
520
|
+
/**
|
|
521
|
+
* The results screen's words for an outcome. In a room the headline depends on who is
|
|
522
|
+
* looking: `viewer` is the player's seat, or null for a spectator, who hears who won.
|
|
523
|
+
*/
|
|
524
|
+
var resultView = (outcome, seats, mode, viewer = null) => {
|
|
525
|
+
const winner = outcome.placements.find((p) => p.place === 1)?.seat;
|
|
526
|
+
const headline = (() => {
|
|
527
|
+
switch (outcome.result) {
|
|
528
|
+
case "won":
|
|
529
|
+
if (mode === "server") {
|
|
530
|
+
if (viewer === null) return `${nameOf(seats, winner)} wins!`;
|
|
531
|
+
return outcome.placements.find((p) => p.seat === viewer)?.place === 1 ? "You won!" : `${nameOf(seats, winner)} wins`;
|
|
532
|
+
}
|
|
533
|
+
return mode === "pass-and-play" ? `${nameOf(seats, winner)} wins!` : "You won!";
|
|
534
|
+
case "lost": return "You lost";
|
|
535
|
+
case "draw": return "It's a draw";
|
|
536
|
+
case "completed": return "Complete!";
|
|
537
|
+
case "abandoned": return "Game over";
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
return {
|
|
541
|
+
result: outcome.result,
|
|
542
|
+
headline,
|
|
543
|
+
summary: outcome.summary,
|
|
544
|
+
elapsedMs: outcome.elapsedMs ?? null,
|
|
545
|
+
assisted: assistLine(outcome)
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
/** One preference control's change, parsed from its name and value, or undefined if invalid. */
|
|
549
|
+
var parsePreferenceChange = (name, value, checked) => {
|
|
550
|
+
switch (name) {
|
|
551
|
+
case "reducedMotion": return value === "system" || value === "reduce" || value === "no-preference" ? { reducedMotion: value } : void 0;
|
|
552
|
+
case "theme": return value === "system" || value === "light" || value === "dark" ? { theme: value } : void 0;
|
|
553
|
+
case "sound": return checked === void 0 ? void 0 : { sound: checked };
|
|
554
|
+
case "singleKeyShortcuts": return checked === void 0 ? void 0 : { singleKeyShortcuts: checked };
|
|
555
|
+
case "aiContentAccepted": return checked === void 0 ? void 0 : { aiContentAccepted: checked };
|
|
556
|
+
case "volume": {
|
|
557
|
+
const percent = Number(value);
|
|
558
|
+
return value !== void 0 && value !== "" && Number.isInteger(percent) && percent >= 0 && percent <= 100 ? { volume: percent / 100 } : void 0;
|
|
559
|
+
}
|
|
560
|
+
default: return;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
//#endregion
|
|
564
|
+
export { AI_NOTICE, GUEST_NAME, MODES, MODE_LABELS, ONLINE_CHECKING, ONLINE_UNREACHABLE, accessGate, assistLine, clockLabel, clockOptions, clockValue, computerSeatsFor, defaultName, filledSeats, formatElapsed, hostedOnline, initialSetup, isLauncherGame, isRoomCode, isStoredPreferences, isoDuration, launcherGame, modeOptions, needsAiNotice, offerAnnouncement, offerText, parseClockValue, parseHostedGames, parsePreferenceChange, parseRoomCode, parseStageOutput, pickMode, playerCounts, resultView, seatsFor, spacedCode, startProblem, startingCount, toLocalMode, toStageMode };
|
|
565
|
+
|
|
566
|
+
//# sourceMappingURL=model.js.map
|