@johnmorrisdotca/kyogijo-stadiums 0.61.0 → 0.63.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 +19 -0
- package/README.md +4 -3
- package/catalogue.json +1 -1
- package/dist/catalogue.data.js +1 -1
- package/dist/feeds-entry.d.ts +7 -0
- package/dist/feeds-entry.js +6 -0
- package/dist/feeds.core.d.ts +55 -0
- package/dist/feeds.core.js +21 -0
- package/dist/feeds.d.ts +26 -0
- package/dist/feeds.dom.d.ts +1 -0
- package/dist/feeds.dom.js +34 -0
- package/dist/feeds.fetch.d.ts +62 -0
- package/dist/feeds.fetch.js +177 -0
- package/dist/feeds.js +211 -0
- package/dist/feeds.nhl.d.ts +28 -0
- package/dist/feeds.nhl.js +132 -0
- package/dist/judging.mount.js +1 -1
- package/dist/mount-entry.d.ts +1 -0
- package/dist/mount.d.ts +4 -3
- package/dist/mount.js +3 -2
- package/dist/replay-entry.d.ts +7 -3
- package/dist/replay-entry.js +3 -1
- package/dist/replay.board.d.ts +75 -4
- package/dist/replay.board.js +113 -13
- package/dist/replay.d.ts +1 -1
- package/dist/replay.deadtime.d.ts +58 -0
- package/dist/replay.deadtime.js +169 -0
- package/dist/replay.feed.d.ts +54 -0
- package/dist/replay.feed.js +17 -0
- package/dist/replay.hockey.d.ts +143 -0
- package/dist/replay.hockey.js +276 -0
- package/dist/replay.js +12 -6
- package/dist/replay.types.d.ts +41 -0
- package/dist/strings.core.d.ts +1 -0
- package/dist/strings.en.js +1 -1
- package/dist/strings.ja.js +1 -1
- package/dist/turn-entry.d.ts +1 -0
- package/dist/turn.dom.d.ts +3 -1
- package/dist/turn.dom.js +5 -4
- package/dist/turn.replay.board.d.ts +1 -1
- package/dist/turn.replay.board.js +20 -3
- package/dist/venue.boards.d.ts +6 -0
- package/dist/venue.boards.js +20 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -1
package/dist/feeds.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// A feed of a game read into a replay: kyogijo's own open timeline (a bare timeline, or a game file with its sides and periods) or a play-by-play of a shape that is common, each read by a pure function of
|
|
2
|
+
// its own (an adapter). The answer is the user's: it comes from a file they picked, text they pasted or an address they gave, and nothing in it is trusted. Every number is checked, every list is capped,
|
|
3
|
+
// and a name is only ever text. Nothing here fetches: `feeds.fetch.ts` is the page's side.
|
|
4
|
+
import { FEED_LIMITS, FeedError, feedText } from "./feeds.core.js";
|
|
5
|
+
import { nhlPlayByPlay } from "./feeds.nhl.js";
|
|
6
|
+
export { FEED_FORMATS, FEED_LIMITS, FeedError, feedText } from "./feeds.core.js";
|
|
7
|
+
const TEAMS = ["home", "away", "ball", "puck", "official"];
|
|
8
|
+
const isNum = (value) => typeof value === "number" && Number.isFinite(value);
|
|
9
|
+
const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
/** A side of a feed's game, as a scoreboard writes it: its letters, name and colour as text. */
|
|
11
|
+
const sideOf = (value) => {
|
|
12
|
+
if (!isObj(value))
|
|
13
|
+
return undefined;
|
|
14
|
+
const name = isObj(value.name) ? feedText(value.name.en ?? Object.values(value.name)[0]) : feedText(value.name);
|
|
15
|
+
const side = {};
|
|
16
|
+
const abbr = feedText(value.abbr, 8);
|
|
17
|
+
const color = feedText(value.color, 32);
|
|
18
|
+
if (abbr !== undefined)
|
|
19
|
+
side.abbr = abbr;
|
|
20
|
+
if (name !== undefined)
|
|
21
|
+
side.name = name;
|
|
22
|
+
if (color !== undefined && /^#[0-9a-f]{3,8}$/i.test(color))
|
|
23
|
+
side.color = color;
|
|
24
|
+
return side;
|
|
25
|
+
};
|
|
26
|
+
/** An object of a frame, checked: an id, a known team and a place, else nothing. */
|
|
27
|
+
function objectOf(value) {
|
|
28
|
+
if (!isObj(value) || !isNum(value.x) || !isNum(value.y))
|
|
29
|
+
return undefined;
|
|
30
|
+
const id = feedText(value.id, 40);
|
|
31
|
+
const team = TEAMS.find((t) => t === value.team);
|
|
32
|
+
if (id === undefined || team === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
const o = { id, team, x: value.x, y: value.y };
|
|
35
|
+
const label = feedText(value.label);
|
|
36
|
+
if (label !== undefined)
|
|
37
|
+
o.label = label;
|
|
38
|
+
if (isNum(value.number) || typeof value.number === "string")
|
|
39
|
+
o.number = typeof value.number === "string" ? (feedText(value.number, 4) ?? "") : value.number;
|
|
40
|
+
return o;
|
|
41
|
+
}
|
|
42
|
+
/** An event, checked: a time and a kind, and what else it has that is of the right type. */
|
|
43
|
+
function eventOf(value) {
|
|
44
|
+
if (!isObj(value) || !isNum(value.t))
|
|
45
|
+
return undefined;
|
|
46
|
+
const kind = feedText(value.kind, 24);
|
|
47
|
+
if (kind === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
const e = { t: value.t, kind };
|
|
50
|
+
if (value.team === "home" || value.team === "away")
|
|
51
|
+
e.team = value.team;
|
|
52
|
+
for (const key of ["x", "y", "x2", "y2", "until"])
|
|
53
|
+
if (isNum(value[key]))
|
|
54
|
+
e[key] = value[key];
|
|
55
|
+
for (const key of ["by", "to"]) {
|
|
56
|
+
const text = feedText(value[key], 40);
|
|
57
|
+
if (text !== undefined)
|
|
58
|
+
e[key] = text;
|
|
59
|
+
}
|
|
60
|
+
const label = feedText(value.label);
|
|
61
|
+
if (label !== undefined)
|
|
62
|
+
e.label = label;
|
|
63
|
+
return e;
|
|
64
|
+
}
|
|
65
|
+
/** A period, checked: a start and an end no earlier than it, and its number and game-clock length when they are numbers. */
|
|
66
|
+
function periodOf(value) {
|
|
67
|
+
if (!isObj(value) || !isNum(value.start) || !isNum(value.end) || value.end < value.start)
|
|
68
|
+
return undefined;
|
|
69
|
+
return { start: value.start, end: value.end, ...(isNum(value.number) ? { number: value.number } : {}), ...(isNum(value.length) && value.length > 0 ? { length: value.length } : {}) };
|
|
70
|
+
}
|
|
71
|
+
/** A stoppage, checked: a start and an end no earlier than it, and its reason, the place of its restart and the side it was called against when they are of the right type. */
|
|
72
|
+
function stoppageOf(value) {
|
|
73
|
+
if (!isObj(value) || !isNum(value.start) || !isNum(value.end) || value.end < value.start)
|
|
74
|
+
return undefined;
|
|
75
|
+
const s = { start: value.start, end: value.end };
|
|
76
|
+
const reason = feedText(value.reason, 24);
|
|
77
|
+
if (reason !== undefined)
|
|
78
|
+
s.reason = reason;
|
|
79
|
+
if (isNum(value.x))
|
|
80
|
+
s.x = value.x;
|
|
81
|
+
if (isNum(value.y))
|
|
82
|
+
s.y = value.y;
|
|
83
|
+
if (value.team === "home" || value.team === "away")
|
|
84
|
+
s.team = value.team;
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
/** A penalty, checked: a side, a start and its minutes on the game clock, and who serves it, when he leaves the box, its kind, whether his side plays short and whether a goal ended it, when they are of the right type. */
|
|
88
|
+
function penaltyOf(value) {
|
|
89
|
+
if (!isObj(value) || (value.team !== "home" && value.team !== "away") || !isNum(value.start) || !isNum(value.minutes) || value.minutes <= 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
const p = { team: value.team, start: value.start, minutes: value.minutes };
|
|
92
|
+
if (isNum(value.end) && value.end >= value.start)
|
|
93
|
+
p.end = value.end;
|
|
94
|
+
const by = feedText(value.by, 40);
|
|
95
|
+
if (by !== undefined)
|
|
96
|
+
p.by = by;
|
|
97
|
+
const kind = feedText(value.kind, 24);
|
|
98
|
+
if (kind !== undefined)
|
|
99
|
+
p.kind = kind;
|
|
100
|
+
if (typeof value.short === "boolean")
|
|
101
|
+
p.short = value.short;
|
|
102
|
+
if (value.endedBy === "goal")
|
|
103
|
+
p.endedBy = "goal";
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
const FROM = /^[a-z0-9-]{1,32}$/;
|
|
107
|
+
/**
|
|
108
|
+
* Read kyogijo's own open timeline: a bare timeline (`{ frames, events?, periods?, stoppages?, penalties?, teams?, from? }`) or a game file (`{ kind: "kyogijo-game", sport, teams, periods, timeline }`, as
|
|
109
|
+
* `examples/games` has them). Every frame, object, event, period, stoppage and penalty is checked; what is not of the format is left out, and a timeline with no frame is an error.
|
|
110
|
+
*/
|
|
111
|
+
export function kyogijoFeed(json) {
|
|
112
|
+
if (!isObj(json))
|
|
113
|
+
throw new FeedError("format", "not a kyogijo timeline: the answer is not a JSON object");
|
|
114
|
+
const game = json.kind === "kyogijo-game" && isObj(json.timeline);
|
|
115
|
+
const source = game ? json.timeline : json;
|
|
116
|
+
if (!Array.isArray(source.frames))
|
|
117
|
+
throw new FeedError("format", "not a kyogijo timeline: it has no list of frames");
|
|
118
|
+
const frames = [];
|
|
119
|
+
for (const f of source.frames.slice(0, FEED_LIMITS.frames)) {
|
|
120
|
+
if (!isObj(f) || !isNum(f.t) || !Array.isArray(f.objects))
|
|
121
|
+
continue;
|
|
122
|
+
const objects = f.objects.slice(0, FEED_LIMITS.objects).map(objectOf).filter((o) => o !== undefined);
|
|
123
|
+
const frame = { t: f.t, objects };
|
|
124
|
+
if (f.possession === null || typeof f.possession === "string")
|
|
125
|
+
frame.possession = f.possession === null ? null : feedText(f.possession, 40) ?? null;
|
|
126
|
+
frames.push(frame);
|
|
127
|
+
}
|
|
128
|
+
if (frames.length === 0)
|
|
129
|
+
throw new FeedError("empty", "the timeline has no frame to play");
|
|
130
|
+
const events = (Array.isArray(source.events) ? source.events.slice(0, FEED_LIMITS.events) : []).map(eventOf).filter((e) => e !== undefined);
|
|
131
|
+
const periodsIn = game ? json.periods : source.periods;
|
|
132
|
+
const periods = (Array.isArray(periodsIn) ? periodsIn.slice(0, FEED_LIMITS.periods) : []).map(periodOf).filter((p) => p !== undefined);
|
|
133
|
+
const stoppages = (Array.isArray(source.stoppages) ? source.stoppages.slice(0, FEED_LIMITS.stoppages) : [])
|
|
134
|
+
.map(stoppageOf)
|
|
135
|
+
.filter((s) => s !== undefined)
|
|
136
|
+
.sort((a, b) => a.start - b.start);
|
|
137
|
+
const penalties = (Array.isArray(source.penalties) ? source.penalties.slice(0, FEED_LIMITS.penalties) : [])
|
|
138
|
+
.map(penaltyOf)
|
|
139
|
+
.filter((p) => p !== undefined)
|
|
140
|
+
.sort((a, b) => a.start - b.start);
|
|
141
|
+
const teamsIn = game ? json.teams : source.teams;
|
|
142
|
+
const home = isObj(teamsIn) ? sideOf(teamsIn.home) : undefined;
|
|
143
|
+
const away = isObj(teamsIn) ? sideOf(teamsIn.away) : undefined;
|
|
144
|
+
const timeline = {
|
|
145
|
+
frames,
|
|
146
|
+
events,
|
|
147
|
+
...(typeof source.from === "string" && FROM.test(source.from) ? { from: source.from } : {}),
|
|
148
|
+
...(periods.length > 0 ? { periods } : {}),
|
|
149
|
+
...(source.clock === "up" || source.clock === "down" ? { clock: source.clock } : {}),
|
|
150
|
+
...(stoppages.length > 0 ? { stoppages } : {}),
|
|
151
|
+
...(penalties.length > 0 ? { penalties } : {}),
|
|
152
|
+
};
|
|
153
|
+
const players = {};
|
|
154
|
+
const rosters = game && isObj(json.rosters) ? json.rosters : {};
|
|
155
|
+
for (const side of ["home", "away"]) {
|
|
156
|
+
const roster = rosters[side];
|
|
157
|
+
if (!Array.isArray(roster))
|
|
158
|
+
continue;
|
|
159
|
+
for (const p of roster.slice(0, 80)) {
|
|
160
|
+
const id = isObj(p) ? feedText(p.id, 40) : undefined;
|
|
161
|
+
if (id === undefined || !isObj(p))
|
|
162
|
+
continue;
|
|
163
|
+
const name = isObj(p.name) ? feedText(p.name.en ?? Object.values(p.name)[0]) : feedText(p.name);
|
|
164
|
+
players[id] = { side, ...(isNum(p.number) ? { number: p.number } : {}), ...(name === undefined ? {} : { name }) };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const sport = game ? feedText(json.sport, 24) : undefined;
|
|
168
|
+
return { format: "kyogijo", ...(sport === undefined ? {} : { sport }), timeline, teams: { home: home ?? {}, away: away ?? {} }, ended: true, players };
|
|
169
|
+
}
|
|
170
|
+
/** Which format a feed's answer is in: a timeline or a game file of kyogijo's, or a play-by-play shaped as the NHL's. `undefined` when it is neither. */
|
|
171
|
+
export function feedFormatOf(json) {
|
|
172
|
+
if (!isObj(json))
|
|
173
|
+
return undefined;
|
|
174
|
+
if (json.kind === "kyogijo-game" || Array.isArray(json.frames))
|
|
175
|
+
return "kyogijo";
|
|
176
|
+
if (Array.isArray(json.plays) && (isObj(json.homeTeam) || isObj(json.awayTeam)))
|
|
177
|
+
return "nhl";
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Read a feed's answer into a game: in the format asked, or the one `feedFormatOf` finds. Throws a `FeedError` (`format`, `empty`) that says what was wrong in plain words.
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* import { readFeed } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
|
|
185
|
+
* const answer = { frames: [{ t: 0, objects: [{ id: "puck", team: "puck", x: 0, y: 0 }] }, { t: 30, objects: [{ id: "puck", team: "puck", x: 80, y: 4 }] }], events: [{ t: 30, kind: "goal", team: "home" }], from: "nhl-api" };
|
|
186
|
+
* const game = readFeed(answer);
|
|
187
|
+
* console.log(game.format, game.timeline.frames.length, game.timeline.events?.length);
|
|
188
|
+
* try { readFeed({ hello: "world" }); } catch (error) { console.log((error as Error).name, (error as { kind?: string }).kind); }
|
|
189
|
+
* // kyogijo 2 1
|
|
190
|
+
* // FeedError format
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export function readFeed(json, format = "auto") {
|
|
194
|
+
const as = format === "auto" ? feedFormatOf(json) : format;
|
|
195
|
+
if (as === "kyogijo")
|
|
196
|
+
return kyogijoFeed(json);
|
|
197
|
+
if (as === "nhl")
|
|
198
|
+
return nhlPlayByPlay(json);
|
|
199
|
+
throw new FeedError("format", "not a feed this package reads: neither a kyogijo timeline nor an NHL-style play-by-play");
|
|
200
|
+
}
|
|
201
|
+
/** Read text (a file or what was pasted) as JSON and then as a feed. */
|
|
202
|
+
export function readFeedText(text, format = "auto") {
|
|
203
|
+
let json;
|
|
204
|
+
try {
|
|
205
|
+
json = JSON.parse(text);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new FeedError("format", "not JSON: the text could not be read");
|
|
209
|
+
}
|
|
210
|
+
return readFeed(json, format);
|
|
211
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type FeedGame } from "./feeds.core.ts";
|
|
2
|
+
/** A regulation period, and an overtime in the regular season (the playoffs play twenty-minute overtimes): seconds. */
|
|
3
|
+
export declare const NHL_PERIOD = 1200;
|
|
4
|
+
export declare const NHL_OVERTIME = 300;
|
|
5
|
+
/**
|
|
6
|
+
* Read a play-by-play shaped as the NHL's public one into a game: the puck's places at the plays that have one (between them it moves in a straight line), a mark for each shot, goal and whistle, the periods with their
|
|
7
|
+
* clocks (a twenty-minute period, the board counting it down, an overtime of five minutes in the regular season and twenty in the playoffs, the shoot-out left out), each side's letters and name from the feed, and the players it names
|
|
8
|
+
* (by `p` and their id). Times are the game's, so the board's clock is the game's: period 2 starts at 1200 s. Throws a `FeedError` when it is not of the shape, or has no play with a place.
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { nhlPlayByPlay } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
|
|
12
|
+
* const answer = {
|
|
13
|
+
* gameState: "LIVE", gameType: 2,
|
|
14
|
+
* homeTeam: { id: 1, abbrev: "AAA", commonName: { default: "Aardvarks" } }, awayTeam: { id: 2, abbrev: "BBB" },
|
|
15
|
+
* plays: [
|
|
16
|
+
* { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "00:00", typeDescKey: "faceoff", sortOrder: 1, details: { xCoord: 0, yCoord: 0 } },
|
|
17
|
+
* { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "01:30", typeDescKey: "goal", sortOrder: 2, details: { xCoord: 80, yCoord: -4, eventOwnerTeamId: 1, scoringPlayerId: 9 } },
|
|
18
|
+
* ],
|
|
19
|
+
* rosterSpots: [{ teamId: 1, playerId: 9, sweaterNumber: 19, firstName: { default: "Ada" }, lastName: { default: "Example" } }],
|
|
20
|
+
* };
|
|
21
|
+
* const game = nhlPlayByPlay(answer);
|
|
22
|
+
* console.log(game.timeline.events, game.teams.home, game.ended);
|
|
23
|
+
* console.log(game.players.p9);
|
|
24
|
+
* // [ { t: 90, kind: 'goal', x: 80, y: -4, team: 'home', by: 'p9' } ] { abbr: 'AAA', name: 'Aardvarks' } false
|
|
25
|
+
* // { side: 'home', number: 19, name: 'Ada Example' }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function nhlPlayByPlay(json: unknown): FeedGame;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// An adapter: a play-by-play shaped as the NHL's public one (`plays`, each with `periodDescriptor`, `timeInPeriod`, `typeDescKey` and `details` with `xCoord` and `yCoord` in feet from centre ice; `homeTeam` and
|
|
2
|
+
// `awayTeam` with their `id` and `abbrev`; `rosterSpots`; `gameState`) read into a replay. A play-by-play has events and no tracking: the puck goes from one event's place to the next in a straight line (it is
|
|
3
|
+
// modelled between the known places), the shots, the goals and the whistles are marked where they were, and the clock is the game's (twenty minutes a period). Pure; every value is checked. The shape was read
|
|
4
|
+
// from public descriptions of the answer; tests run on answers we made up.
|
|
5
|
+
import { FEED_LIMITS, FeedError, feedText } from "./feeds.core.js";
|
|
6
|
+
const isNum = (value) => typeof value === "number" && Number.isFinite(value);
|
|
7
|
+
const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
/** A text field of the NHL's shape: a string, or `{ default: string }`. */
|
|
9
|
+
const textOf = (value, most) => feedText(isObj(value) ? value.default : value, most);
|
|
10
|
+
/** `mm:ss` as seconds; `undefined` for anything else. */
|
|
11
|
+
const secondsOf = (value) => {
|
|
12
|
+
const m = typeof value === "string" ? /^(\d{1,3}):([0-5]\d)$/.exec(value.trim()) : null;
|
|
13
|
+
return m === null ? undefined : Number(m[1]) * 60 + Number(m[2]);
|
|
14
|
+
};
|
|
15
|
+
/** A regulation period, and an overtime in the regular season (the playoffs play twenty-minute overtimes): seconds. */
|
|
16
|
+
export const NHL_PERIOD = 1200;
|
|
17
|
+
export const NHL_OVERTIME = 300;
|
|
18
|
+
/** What a kind of play is on the replay: the shots, the goal, and a whistle for a stoppage or a penalty. The rest only moves the puck. */
|
|
19
|
+
const KINDS = { "shot-on-goal": "shot", "missed-shot": "shot", "blocked-shot": "shot", goal: "goal", stoppage: "whistle", penalty: "whistle", "delayed-penalty": "whistle" };
|
|
20
|
+
/** The states of a game the NHL's shape says are over. */
|
|
21
|
+
const OVER = new Set(["OFF", "FINAL"]);
|
|
22
|
+
/**
|
|
23
|
+
* Read a play-by-play shaped as the NHL's public one into a game: the puck's places at the plays that have one (between them it moves in a straight line), a mark for each shot, goal and whistle, the periods with their
|
|
24
|
+
* clocks (a twenty-minute period, the board counting it down, an overtime of five minutes in the regular season and twenty in the playoffs, the shoot-out left out), each side's letters and name from the feed, and the players it names
|
|
25
|
+
* (by `p` and their id). Times are the game's, so the board's clock is the game's: period 2 starts at 1200 s. Throws a `FeedError` when it is not of the shape, or has no play with a place.
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* import { nhlPlayByPlay } from "@johnmorrisdotca/kyogijo-stadiums/feeds";
|
|
29
|
+
* const answer = {
|
|
30
|
+
* gameState: "LIVE", gameType: 2,
|
|
31
|
+
* homeTeam: { id: 1, abbrev: "AAA", commonName: { default: "Aardvarks" } }, awayTeam: { id: 2, abbrev: "BBB" },
|
|
32
|
+
* plays: [
|
|
33
|
+
* { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "00:00", typeDescKey: "faceoff", sortOrder: 1, details: { xCoord: 0, yCoord: 0 } },
|
|
34
|
+
* { periodDescriptor: { number: 1, periodType: "REG" }, timeInPeriod: "01:30", typeDescKey: "goal", sortOrder: 2, details: { xCoord: 80, yCoord: -4, eventOwnerTeamId: 1, scoringPlayerId: 9 } },
|
|
35
|
+
* ],
|
|
36
|
+
* rosterSpots: [{ teamId: 1, playerId: 9, sweaterNumber: 19, firstName: { default: "Ada" }, lastName: { default: "Example" } }],
|
|
37
|
+
* };
|
|
38
|
+
* const game = nhlPlayByPlay(answer);
|
|
39
|
+
* console.log(game.timeline.events, game.teams.home, game.ended);
|
|
40
|
+
* console.log(game.players.p9);
|
|
41
|
+
* // [ { t: 90, kind: 'goal', x: 80, y: -4, team: 'home', by: 'p9' } ] { abbr: 'AAA', name: 'Aardvarks' } false
|
|
42
|
+
* // { side: 'home', number: 19, name: 'Ada Example' }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function nhlPlayByPlay(json) {
|
|
46
|
+
if (!isObj(json) || !Array.isArray(json.plays))
|
|
47
|
+
throw new FeedError("format", "not an NHL-style play-by-play: it has no list of plays");
|
|
48
|
+
const home = isObj(json.homeTeam) ? json.homeTeam : {};
|
|
49
|
+
const away = isObj(json.awayTeam) ? json.awayTeam : {};
|
|
50
|
+
const sideOf = (teamId) => (teamId !== undefined && teamId === home.id ? "home" : teamId !== undefined && teamId === away.id ? "away" : undefined);
|
|
51
|
+
const playoffs = json.gameType === 3;
|
|
52
|
+
const lengthOf = (number, type) => (type === "OT" || (type === undefined && number > 3) ? (playoffs ? NHL_PERIOD : NHL_OVERTIME) : NHL_PERIOD);
|
|
53
|
+
// The plays in order, the shoot-out left out (it has no clock, and its goals are not the score's).
|
|
54
|
+
const plays = json.plays
|
|
55
|
+
.slice(0, FEED_LIMITS.events)
|
|
56
|
+
.filter(isObj)
|
|
57
|
+
.map((play, i) => ({ play, order: isNum(play.sortOrder) ? play.sortOrder : i }))
|
|
58
|
+
.sort((a, b) => a.order - b.order)
|
|
59
|
+
.map((one) => one.play);
|
|
60
|
+
const periods = new Map();
|
|
61
|
+
const frames = [];
|
|
62
|
+
const events = [];
|
|
63
|
+
/** The time a period starts on the game's clock: the lengths of the periods before it. */
|
|
64
|
+
const startOf = (number, type) => {
|
|
65
|
+
let start = 0;
|
|
66
|
+
for (let p = 1; p < number; p += 1)
|
|
67
|
+
start += lengthOf(p, p <= 3 ? "REG" : type);
|
|
68
|
+
return start;
|
|
69
|
+
};
|
|
70
|
+
for (const play of plays) {
|
|
71
|
+
const d = isObj(play.periodDescriptor) ? play.periodDescriptor : {};
|
|
72
|
+
const number = isNum(d.number) && d.number >= 1 && d.number <= FEED_LIMITS.periods ? Math.trunc(d.number) : undefined;
|
|
73
|
+
if (number === undefined || d.periodType === "SO")
|
|
74
|
+
continue;
|
|
75
|
+
const length = lengthOf(number, d.periodType);
|
|
76
|
+
const start = startOf(number, d.periodType);
|
|
77
|
+
if (!periods.has(number))
|
|
78
|
+
periods.set(number, { number, start, end: start + length });
|
|
79
|
+
const into = secondsOf(play.timeInPeriod);
|
|
80
|
+
if (into === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const t = start + Math.min(length, into);
|
|
83
|
+
const details = isObj(play.details) ? play.details : {};
|
|
84
|
+
const placed = isNum(details.xCoord) && isNum(details.yCoord);
|
|
85
|
+
const [x, y] = placed ? [Math.max(-100, Math.min(100, details.xCoord)), Math.max(-42.5, Math.min(42.5, details.yCoord))] : [0, 0];
|
|
86
|
+
const type = typeof play.typeDescKey === "string" ? play.typeDescKey : "";
|
|
87
|
+
// A period starts with the puck at centre ice.
|
|
88
|
+
if (type === "period-start")
|
|
89
|
+
frames.push({ t, objects: [{ id: "puck", team: "puck", x: 0, y: 0 }] });
|
|
90
|
+
if (placed)
|
|
91
|
+
frames.push({ t, objects: [{ id: "puck", team: "puck", x, y }] });
|
|
92
|
+
const kind = KINDS[type];
|
|
93
|
+
if (kind === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
const team = sideOf(details.eventOwnerTeamId);
|
|
96
|
+
const by = isNum(details.scoringPlayerId) ? details.scoringPlayerId : isNum(details.shootingPlayerId) ? details.shootingPlayerId : isNum(details.committedByPlayerId) ? details.committedByPlayerId : undefined;
|
|
97
|
+
events.push({ t, kind, ...(placed ? { x, y } : {}), ...(team === undefined ? {} : { team }), ...(by === undefined ? {} : { by: `p${by}` }) });
|
|
98
|
+
}
|
|
99
|
+
if (frames.length === 0)
|
|
100
|
+
throw new FeedError("empty", "the play-by-play has no play with a place on the ice yet");
|
|
101
|
+
// Frames at one time keep the last; the timeline is in order.
|
|
102
|
+
const byTime = new Map();
|
|
103
|
+
for (const f of frames)
|
|
104
|
+
byTime.set(f.t, f);
|
|
105
|
+
const ordered = [...byTime.values()].sort((a, b) => a.t - b.t);
|
|
106
|
+
const side = (team) => {
|
|
107
|
+
const abbr = textOf(team.abbrev, 8);
|
|
108
|
+
const name = textOf(team.commonName) ?? textOf(team.name) ?? textOf(team.placeName);
|
|
109
|
+
return { ...(abbr === undefined ? {} : { abbr }), ...(name === undefined ? {} : { name }) };
|
|
110
|
+
};
|
|
111
|
+
const players = {};
|
|
112
|
+
if (Array.isArray(json.rosterSpots)) {
|
|
113
|
+
for (const spot of json.rosterSpots.slice(0, 120)) {
|
|
114
|
+
if (!isObj(spot) || !isNum(spot.playerId))
|
|
115
|
+
continue;
|
|
116
|
+
const s = sideOf(spot.teamId);
|
|
117
|
+
if (s === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
const name = [textOf(spot.firstName, 40), textOf(spot.lastName, 40)].filter((part) => part !== undefined).join(" ");
|
|
120
|
+
players[`p${spot.playerId}`] = { side: s, ...(isNum(spot.sweaterNumber) ? { number: spot.sweaterNumber } : {}), ...(name === "" ? {} : { name }) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const state = typeof json.gameState === "string" ? json.gameState : "";
|
|
124
|
+
return {
|
|
125
|
+
format: "nhl",
|
|
126
|
+
sport: "nhl",
|
|
127
|
+
timeline: { from: "nhl-api", frames: ordered, events, periods: [...periods.values()].sort((a, b) => a.start - b.start) },
|
|
128
|
+
teams: { home: side(home), away: side(away) },
|
|
129
|
+
ended: OVER.has(state),
|
|
130
|
+
players,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/dist/judging.mount.js
CHANGED
|
@@ -62,7 +62,7 @@ export function mountJudgingBroadcast(host, options, settings = {}) {
|
|
|
62
62
|
direction: () => turn.direction(),
|
|
63
63
|
setDirection: (direction) => turn.setDirection(direction),
|
|
64
64
|
togglePlay: () => turn.togglePlay(),
|
|
65
|
-
replay:
|
|
65
|
+
replay: turn.replay,
|
|
66
66
|
get quality() {
|
|
67
67
|
return turn.quality;
|
|
68
68
|
},
|
package/dist/mount-entry.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ import "./full.ts";
|
|
|
2
2
|
import "./venues.all.ts";
|
|
3
3
|
export { containerAspect, kyogijoElement, mountKyogijo } from "./mount.ts";
|
|
4
4
|
export type { KyogijoMount, KyogijoReplay, MountSettings } from "./mount.ts";
|
|
5
|
+
export type { FeedReplay, FeedReplayOptions, ReplayCall, ReplayFollow, ReplayFollowOptions } from "./replay.feed.ts";
|
package/dist/mount.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReplayController } from "./replay.control.ts";
|
|
2
|
-
import
|
|
2
|
+
import { type ReplayCall } from "./replay.feed.ts";
|
|
3
3
|
import type { ScoreOptions, SurfaceOptions } from "./types.ts";
|
|
4
4
|
/** A replay on a mount: the controller of its clock, and `destroy` to take the replay away and leave the surface. */
|
|
5
5
|
export type KyogijoReplay = ReplayController & {
|
|
@@ -16,9 +16,10 @@ export interface KyogijoMount {
|
|
|
16
16
|
/**
|
|
17
17
|
* Play tracking data on the surface: the players, the ball and the events of a timeline, with controls under the drawing (play, pause, step,
|
|
18
18
|
* speed, loop, position), the keyboard (space, the arrow keys, Home and End) and a live region for goals and shots. Only the marks that move are
|
|
19
|
-
* moved, on animation frames. A reader who prefers reduced motion gets steps, and nothing plays by itself; a hidden page pauses. `update` keeps the replay.
|
|
19
|
+
* moved, on animation frames. A reader who prefers reduced motion gets steps, and nothing plays by itself; a hidden page pauses. `update` keeps the replay. `replay.fromUrl(url, options)` plays a feed from an
|
|
20
|
+
* address the reader gives, and `replay.follow(url, options)` follows one live, once the `/feeds` entry is loaded (see `docs/feeds.md`).
|
|
20
21
|
*/
|
|
21
|
-
replay
|
|
22
|
+
replay: ReplayCall;
|
|
22
23
|
/** Stop watching the container and empty it. */
|
|
23
24
|
destroy(): void;
|
|
24
25
|
}
|
package/dist/mount.js
CHANGED
|
@@ -2,6 +2,7 @@ import { drawKyogijo, isPortrait } from "./draw.js";
|
|
|
2
2
|
import { resolveHomeSide } from "./homeside.js";
|
|
3
3
|
import { chooseLeague } from "./leagues.js";
|
|
4
4
|
import { createReplayView } from "./replay.dom.js";
|
|
5
|
+
import { withFeeds } from "./replay.feed.js";
|
|
5
6
|
const isScored = (o) => o.home !== undefined && o.away !== undefined;
|
|
6
7
|
const scoreText = (team) => (team.score === undefined || team.score === null ? "" : String(team.score));
|
|
7
8
|
/**
|
|
@@ -120,7 +121,7 @@ export function mountKyogijo(host, options, settings = {}) {
|
|
|
120
121
|
if (settings.highlight !== false)
|
|
121
122
|
highlightScores(host, before, next);
|
|
122
123
|
},
|
|
123
|
-
replay(timeline, replaySettings = {}) {
|
|
124
|
+
replay: withFeeds((timeline, replaySettings = {}) => {
|
|
124
125
|
if (!alive)
|
|
125
126
|
throw new Error("this mount was destroyed");
|
|
126
127
|
view?.destroy();
|
|
@@ -136,7 +137,7 @@ export function mountKyogijo(host, options, settings = {}) {
|
|
|
136
137
|
render();
|
|
137
138
|
},
|
|
138
139
|
};
|
|
139
|
-
},
|
|
140
|
+
}),
|
|
140
141
|
svg: () => (alive ? host.querySelector("svg.kyogijo") : null),
|
|
141
142
|
orientation: () => (portrait ? "portrait" : "landscape"),
|
|
142
143
|
destroy() {
|
package/dist/replay-entry.d.ts
CHANGED
|
@@ -2,8 +2,12 @@ import "./full.ts";
|
|
|
2
2
|
import "./venues.all.ts";
|
|
3
3
|
export { activeEvents, describeEvent, drawFrame, eventsBetween, interpolate, possession, replayClock, replayEvents, replayInfo, replayLayers, replaySnapshot, replaySummary, replayTable, replayTimes, trails } from "./replay.ts";
|
|
4
4
|
export type { ReplaySnapshot } from "./replay.ts";
|
|
5
|
-
export { boardClock, replayBoard, replayPeriods } from "./replay.board.ts";
|
|
6
|
-
export type { ReplayBoardState } from "./replay.board.ts";
|
|
5
|
+
export { boardClock, penaltiesAt, replayBoard, replayGameClock, replayGameTime, replayPenalties, replayPeriods } from "./replay.board.ts";
|
|
6
|
+
export type { ReplayBoardState, ReplayGameClock, ServedPenalty } from "./replay.board.ts";
|
|
7
|
+
export { addDeadTime, deadSeconds, replayStoppages, restartsBetween, stoppageAt } from "./replay.deadtime.ts";
|
|
8
|
+
export type { DeadStop, DeadTimeRules } from "./replay.deadtime.ts";
|
|
9
|
+
export { HOCKEY_BLUE_LINE, HOCKEY_DEAD_TIME, HOCKEY_DOTS, HOCKEY_PENALTY_MINUTES, HOCKEY_RULES, hockeyFaceoffDot, hockeyFormation, hockeyPenaltyBox, hockeyPenaltyEnds, hockeyPenaltyKind, hockeyStopOf, isHockeyDot, } from "./replay.hockey.ts";
|
|
10
|
+
export type { HockeyCall, HockeyPenaltyKind, HockeyRole, HockeyServed, HockeyStop, HockeyStopAt } from "./replay.hockey.ts";
|
|
7
11
|
export { alongPool, distanceAt, progressOf, raceTimeline } from "./pool.replay.ts";
|
|
8
12
|
export type { Progress, RaceReplayOptions } from "./pool.replay.ts";
|
|
9
13
|
export { formatTime, parseTime } from "./pool.race.ts";
|
|
@@ -11,4 +15,4 @@ export { REPLAY_SPEEDS, REPLAY_SPEED_RANGE, createReplay } from "./replay.contro
|
|
|
11
15
|
export { FOLLOW_LIMITS, FOLLOW_MODES, clampShot, followCamera, followMode, followShot, followStop, followWindow, frameOf, springStep, zoomFor } from "./replay.follow.ts";
|
|
12
16
|
export type { FollowBox, FollowCamera, FollowInput, FollowLimits, FollowMode, FollowShot } from "./replay.follow.ts";
|
|
13
17
|
export type { ReplayController } from "./replay.control.ts";
|
|
14
|
-
export type { ActiveEvent, Possession, ReplayChange, ReplayEvent, ReplayEventKind, ReplayFrame, ReplayLook, ReplayObject, ReplayPeriod, ReplayReason, ReplaySettings, ReplayState, ReplayTeam, ReplayTimeline, ReplayTrail, } from "./replay.types.ts";
|
|
18
|
+
export type { ActiveEvent, Possession, ReplayChange, ReplayEvent, ReplayEventKind, ReplayFrame, ReplayLook, ReplayObject, ReplayPenalty, ReplayPeriod, ReplayReason, ReplaySettings, ReplayState, ReplayStoppage, ReplayTeam, ReplayTimeline, ReplayTrail, } from "./replay.types.ts";
|
package/dist/replay-entry.js
CHANGED
|
@@ -4,7 +4,9 @@ import "./full.js";
|
|
|
4
4
|
// The real venues of the NHL's arenas, as this entry has always had them (the lean district reads the ones it shows).
|
|
5
5
|
import "./venues.all.js";
|
|
6
6
|
export { activeEvents, describeEvent, drawFrame, eventsBetween, interpolate, possession, replayClock, replayEvents, replayInfo, replayLayers, replaySnapshot, replaySummary, replayTable, replayTimes, trails } from "./replay.js";
|
|
7
|
-
export { boardClock, replayBoard, replayPeriods } from "./replay.board.js";
|
|
7
|
+
export { boardClock, penaltiesAt, replayBoard, replayGameClock, replayGameTime, replayPenalties, replayPeriods } from "./replay.board.js";
|
|
8
|
+
export { addDeadTime, deadSeconds, replayStoppages, restartsBetween, stoppageAt } from "./replay.deadtime.js";
|
|
9
|
+
export { HOCKEY_BLUE_LINE, HOCKEY_DEAD_TIME, HOCKEY_DOTS, HOCKEY_PENALTY_MINUTES, HOCKEY_RULES, hockeyFaceoffDot, hockeyFormation, hockeyPenaltyBox, hockeyPenaltyEnds, hockeyPenaltyKind, hockeyStopOf, isHockeyDot, } from "./replay.hockey.js";
|
|
8
10
|
export { alongPool, distanceAt, progressOf, raceTimeline } from "./pool.replay.js";
|
|
9
11
|
export { formatTime, parseTime } from "./pool.race.js";
|
|
10
12
|
export { REPLAY_SPEEDS, REPLAY_SPEED_RANGE, createReplay } from "./replay.control.js";
|
package/dist/replay.board.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReplayPeriod, ReplayTimeline } from "./replay.types.ts";
|
|
1
|
+
import type { ReplayPenalty, ReplayPeriod, ReplayStoppage, ReplayTimeline } from "./replay.types.ts";
|
|
2
2
|
/** The board at a time. */
|
|
3
3
|
export interface ReplayBoardState {
|
|
4
4
|
/** The goals so far: the events of kind `goal` at or before the time, by `team`. */
|
|
@@ -7,6 +7,14 @@ export interface ReplayBoardState {
|
|
|
7
7
|
period?: number;
|
|
8
8
|
/** The game clock as it is written, `19:20`: the time left in the period, or the time gone when the timeline counts `up`. Whole seconds, so it changes once a second of game time at most. */
|
|
9
9
|
clock: string;
|
|
10
|
+
/** Only when the timeline has penalties: the skaters each side has on the ice (`[5, 4]` is a power play for the home side). */
|
|
11
|
+
strength?: [number, number];
|
|
12
|
+
/** Only when the timeline has penalties: each penalty being served at the time, by its side, with its time left as the board writes it (`1:42`), those that make the side play short first. */
|
|
13
|
+
penalties?: {
|
|
14
|
+
team: "home" | "away";
|
|
15
|
+
left: string;
|
|
16
|
+
short: boolean;
|
|
17
|
+
}[];
|
|
10
18
|
}
|
|
11
19
|
/** Seconds on a board's clock, `19:20`. A board has two digits of minutes, so more than 99:59 is shown as 99:59. */
|
|
12
20
|
export declare function boardClock(seconds: number): string;
|
|
@@ -14,10 +22,73 @@ export declare function boardClock(seconds: number): string;
|
|
|
14
22
|
export declare function replayPeriods(timeline: ReplayTimeline): readonly (ReplayPeriod & {
|
|
15
23
|
number?: number;
|
|
16
24
|
})[];
|
|
25
|
+
/** The game clock at a time on the replay's clock. */
|
|
26
|
+
export interface ReplayGameClock {
|
|
27
|
+
/** The period's number, or `undefined` when the timeline has no periods. */
|
|
28
|
+
period?: number;
|
|
29
|
+
/** Seconds of the period played on the game clock, and seconds left of it. */
|
|
30
|
+
played: number;
|
|
31
|
+
left: number;
|
|
32
|
+
/** `false` in a stoppage (and before a period has begun or after it has ended): the game clock is standing still. */
|
|
33
|
+
running: boolean;
|
|
34
|
+
/** The stoppage the time is in, when it is in one. */
|
|
35
|
+
stoppage?: ReplayStoppage;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The game clock at a time `t` on the replay's clock: the period it is in (the last that has begun; the first, before any has) and how much of it has been played. The game clock runs only while play is
|
|
39
|
+
* on: it stands still in each of the timeline's `stoppages`, while the replay's clock moves on through it. A period's `length` is spread over the seconds of it that are not in a stoppage, so a game
|
|
40
|
+
* re-timed to minutes reads like the real one. Pure.
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { replayGameClock } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
44
|
+
* const timeline = {
|
|
45
|
+
* frames: [{ t: 0, objects: [] }, { t: 70, objects: [] }],
|
|
46
|
+
* periods: [{ start: 0, end: 70, length: 1200 }],
|
|
47
|
+
* stoppages: [{ start: 30, end: 40, reason: "goal" }],
|
|
48
|
+
* };
|
|
49
|
+
* const at = (t: number) => { const c = replayGameClock(timeline, t); return `${c.played} ${c.running}`; };
|
|
50
|
+
* console.log(at(15), at(30), at(35), at(40), at(55));
|
|
51
|
+
* // 300 true 600 false 600 false 600 true 900 true
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function replayGameClock(timeline: ReplayTimeline, t: number): ReplayGameClock;
|
|
55
|
+
/**
|
|
56
|
+
* The game time at `t` on the replay's clock: the seconds of game clock played since the first period began (the lengths of the periods before, and what has been played of this one). It stands still in a
|
|
57
|
+
* stoppage, as the game clock does: a penalty's two minutes are two minutes of it.
|
|
58
|
+
*/
|
|
59
|
+
export declare function replayGameTime(timeline: ReplayTimeline, t: number): number;
|
|
60
|
+
export declare function replayPenalties(timeline: ReplayTimeline): readonly ReplayPenalty[];
|
|
61
|
+
/** A penalty being served at a time, and its game seconds left. */
|
|
62
|
+
export interface ServedPenalty {
|
|
63
|
+
penalty: ReplayPenalty;
|
|
64
|
+
left: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The penalties being served at `t` (the player is in the box: from the call to his `end`) with the game seconds left of each, and each side's skaters on the ice: five, less one for each penalty that
|
|
68
|
+
* makes it play short, never fewer than three. A penalty's time left is the game clock from now to its `end` (it runs only while the game clock does), or, with no end or one a power-play goal
|
|
69
|
+
* brought early (`endedBy: "goal"`), its minutes from the drop after the call. Pure.
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* import { penaltiesAt } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
73
|
+
* const timeline = {
|
|
74
|
+
* frames: [{ t: 0, objects: [] }, { t: 60, objects: [] }],
|
|
75
|
+
* periods: [{ start: 0, end: 60, length: 1200 }],
|
|
76
|
+
* penalties: [{ team: "away" as const, by: "a4", start: 10, end: 16, minutes: 2 }],
|
|
77
|
+
* };
|
|
78
|
+
* const at = penaltiesAt(timeline, 12);
|
|
79
|
+
* console.log(at.strength, at.served.map((s) => `${s.penalty.by} ${s.left}`), penaltiesAt(timeline, 16).strength);
|
|
80
|
+
* // [ 5, 4 ] [ 'a4 80' ] [ 5, 5 ]
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare function penaltiesAt(timeline: ReplayTimeline, t: number): {
|
|
84
|
+
served: ServedPenalty[];
|
|
85
|
+
strength: [number, number];
|
|
86
|
+
};
|
|
17
87
|
/**
|
|
18
|
-
* The scoreboard of a replay at a time `t`, in seconds on the timeline's clock. The score is the goals at or before it; the period
|
|
19
|
-
*
|
|
20
|
-
* seconds however long it lasts on the timeline, so a game re-timed to minutes can read like the real one.
|
|
88
|
+
* The scoreboard of a replay at a time `t`, in seconds on the timeline's clock. The score is the goals at or before it; the period and the clock are the game clock's (`replayGameClock`): the clock runs
|
|
89
|
+
* through the period from its full length to nothing, or from nothing up when the timeline counts `up`, and stands still in a stoppage. A period with a `length` of its own runs its clock through that many
|
|
90
|
+
* seconds however long it lasts on the timeline, so a game re-timed to minutes can read like the real one. A timeline with `penalties` adds each side's `strength` and the penalties being served,
|
|
91
|
+
* with their time left (`penaltiesAt`).
|
|
21
92
|
* @example
|
|
22
93
|
* ```ts
|
|
23
94
|
* import { replayBoard } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|