@johnmorrisdotca/kyogijo-stadiums 0.54.0 → 0.55.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 +6 -0
- package/README.md +9 -2
- package/catalogue.json +1 -1
- package/dist/catalogue.data.js +1 -1
- package/dist/circuits-entry.d.ts +14 -0
- package/dist/circuits-entry.js +8 -0
- package/dist/circuits.pace.d.ts +96 -0
- package/dist/circuits.pace.js +131 -0
- package/dist/circuits.race.d.ts +80 -0
- package/dist/circuits.race.dom.d.ts +68 -0
- package/dist/circuits.race.dom.js +434 -0
- package/dist/circuits.race.draw.d.ts +59 -0
- package/dist/circuits.race.draw.js +153 -0
- package/dist/circuits.race.field.d.ts +26 -0
- package/dist/circuits.race.field.js +41 -0
- package/dist/circuits.race.js +292 -0
- package/dist/circuits.race.moment.d.ts +68 -0
- package/dist/circuits.race.moment.js +147 -0
- package/dist/circuits.race.words.d.ts +49 -0
- package/dist/circuits.race.words.js +44 -0
- package/dist/turn.dom.d.ts +1 -1
- package/dist/turn.dom.js +38 -72
- package/dist/turn.frames.d.ts +33 -0
- package/dist/turn.frames.js +102 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A made-up team: its name in both languages, and its colour and the colour of what is written on it. */
|
|
2
|
+
export interface RaceTeam {
|
|
3
|
+
id: string;
|
|
4
|
+
name: {
|
|
5
|
+
en: string;
|
|
6
|
+
ja: string;
|
|
7
|
+
};
|
|
8
|
+
colour: string;
|
|
9
|
+
ink: string;
|
|
10
|
+
}
|
|
11
|
+
/** A made-up driver: a three-letter code, as a timing board shows one, the name in both languages, and the team. */
|
|
12
|
+
export interface RaceDriver {
|
|
13
|
+
code: string;
|
|
14
|
+
name: {
|
|
15
|
+
en: string;
|
|
16
|
+
ja: string;
|
|
17
|
+
};
|
|
18
|
+
team: string;
|
|
19
|
+
}
|
|
20
|
+
/** The teams, from the quickest car to the slowest (`RACE_FIELD` gives the cars of the first `n / 2`). */
|
|
21
|
+
export declare const RACE_TEAMS: readonly RaceTeam[];
|
|
22
|
+
/** The drivers, two to a team in the order of `RACE_TEAMS`. */
|
|
23
|
+
export declare const RACE_DRIVERS: readonly RaceDriver[];
|
|
24
|
+
/** The most cars a race can have, and the fewest. */
|
|
25
|
+
export declare const RACE_CARS_MAX: number;
|
|
26
|
+
export declare const RACE_CARS_MIN = 2;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The made-up field of a simulated race: ten teams of two cars, every name, code and colour invented here. None is a real driver's, team's or series' (no real name, three-letter code or car number is used: a car is
|
|
2
|
+
// told by its team's colour and the position it holds, written on it), and nothing of a real race's result is in it.
|
|
3
|
+
/** The teams, from the quickest car to the slowest (`RACE_FIELD` gives the cars of the first `n / 2`). */
|
|
4
|
+
export const RACE_TEAMS = Object.freeze([
|
|
5
|
+
{ id: "kestrel", name: { en: "Kestrel Racing", ja: "ケストレル・レーシング" }, colour: "#1d4ed8", ink: "#ffffff" },
|
|
6
|
+
{ id: "altair", name: { en: "Altair GP", ja: "アルタイルGP" }, colour: "#0f766e", ink: "#ffffff" },
|
|
7
|
+
{ id: "basalt", name: { en: "Basalt Motorsport", ja: "バサルト・モータースポーツ" }, colour: "#db2777", ink: "#ffffff" },
|
|
8
|
+
{ id: "marlin", name: { en: "Marlin Speed", ja: "マーリン・スピード" }, colour: "#c026d3", ink: "#ffffff" },
|
|
9
|
+
{ id: "juniper", name: { en: "Juniper Racing Team", ja: "ジュニパー・レーシングチーム" }, colour: "#65a30d", ink: "#10151b" },
|
|
10
|
+
{ id: "petrel", name: { en: "Petrel Engineering", ja: "ペトレル・エンジニアリング" }, colour: "#0ea5e9", ink: "#10151b" },
|
|
11
|
+
{ id: "hanabi", name: { en: "Hanabi Racing", ja: "花火レーシング" }, colour: "#b91c1c", ink: "#ffffff" },
|
|
12
|
+
{ id: "northwind", name: { en: "Northwind Motorsport", ja: "ノースウィンド・モータースポーツ" }, colour: "#94a3b8", ink: "#10151b" },
|
|
13
|
+
{ id: "orrery", name: { en: "Orrery Racing", ja: "オーラリー・レーシング" }, colour: "#ca8a04", ink: "#10151b" },
|
|
14
|
+
{ id: "lumen", name: { en: "Lumen Works", ja: "ルーメン・ワークス" }, colour: "#7c3aed", ink: "#ffffff" },
|
|
15
|
+
].map((t) => Object.freeze(t)));
|
|
16
|
+
/** The drivers, two to a team in the order of `RACE_TEAMS`. */
|
|
17
|
+
export const RACE_DRIVERS = Object.freeze([
|
|
18
|
+
{ code: "HLV", name: { en: "Halvorsen", ja: "ハルボルセン" }, team: "kestrel" },
|
|
19
|
+
{ code: "OKF", name: { en: "Okafor", ja: "オカフォー" }, team: "kestrel" },
|
|
20
|
+
{ code: "ISK", name: { en: "Ishikura", ja: "石倉" }, team: "altair" },
|
|
21
|
+
{ code: "RTO", name: { en: "Rautio", ja: "ラウティオ" }, team: "altair" },
|
|
22
|
+
{ code: "BRN", name: { en: "Brennan", ja: "ブレナン" }, team: "basalt" },
|
|
23
|
+
{ code: "DLX", name: { en: "Delacroix", ja: "ドラクロワ" }, team: "basalt" },
|
|
24
|
+
{ code: "LQV", name: { en: "Lindqvist", ja: "リンドクビスト" }, team: "marlin" },
|
|
25
|
+
{ code: "CSN", name: { en: "Castellano", ja: "カステラーノ" }, team: "marlin" },
|
|
26
|
+
{ code: "TKD", name: { en: "Takeda", ja: "武田" }, team: "juniper" },
|
|
27
|
+
{ code: "NVK", name: { en: "Novak", ja: "ノバク" }, team: "juniper" },
|
|
28
|
+
{ code: "ACB", name: { en: "Achterberg", ja: "アハテルベルグ" }, team: "petrel" },
|
|
29
|
+
{ code: "KWL", name: { en: "Kowal", ja: "コワル" }, team: "petrel" },
|
|
30
|
+
{ code: "ARD", name: { en: "Arcadi", ja: "アルカディ" }, team: "hanabi" },
|
|
31
|
+
{ code: "ADB", name: { en: "Adebayo", ja: "アデバヨ" }, team: "hanabi" },
|
|
32
|
+
{ code: "QTR", name: { en: "Quintero", ja: "キンテロ" }, team: "northwind" },
|
|
33
|
+
{ code: "VRG", name: { en: "Varga", ja: "バルガ" }, team: "northwind" },
|
|
34
|
+
{ code: "KUR", name: { en: "Kaur", ja: "カウル" }, team: "orrery" },
|
|
35
|
+
{ code: "FNW", name: { en: "Fenwick", ja: "フェンウィック" }, team: "orrery" },
|
|
36
|
+
{ code: "ESP", name: { en: "Esposito", ja: "エスポジト" }, team: "lumen" },
|
|
37
|
+
{ code: "MRU", name: { en: "Moreau", ja: "モロー" }, team: "lumen" },
|
|
38
|
+
].map((d) => Object.freeze({ ...d, name: Object.freeze(d.name) })));
|
|
39
|
+
/** The most cars a race can have, and the fewest. */
|
|
40
|
+
export const RACE_CARS_MAX = RACE_DRIVERS.length;
|
|
41
|
+
export const RACE_CARS_MIN = 2;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// A simulated race on a circuit: about twenty made-up cars from the grid to the chequered flag over a full race distance, with gaps, overtakes, pit stops and now and then a car that retires, all from one seed. It is a
|
|
2
|
+
// model and says so: each car runs the lap's speeds (`circuits.pace.ts`) a little slower or faster by its car, its fuel, its tyres and the lap, is held up behind a slower car until a straight lets it by, and stops in
|
|
3
|
+
// the pit lane on the laps its strategy picks. The race is worked out once, a tenth of a second at a time, and kept as each car's distance at every second: `circuits.race.moment.ts` reads any moment of it. Pure: the
|
|
4
|
+
// same options always give the same race. No real driver, team, result or time is in it.
|
|
5
|
+
import { circuitOf, circuitPlan } from "./circuits.js";
|
|
6
|
+
import { lineLength, nearestOn } from "./circuits.geo.js";
|
|
7
|
+
import { RACE_PACE, paceProfile, pullAt, speedAt } from "./circuits.pace.js";
|
|
8
|
+
import { RACE_CARS_MAX, RACE_CARS_MIN, RACE_DRIVERS, RACE_TEAMS } from "./circuits.race.field.js";
|
|
9
|
+
import { FEET_PER_METRE as FT } from "./geometry.js";
|
|
10
|
+
/** The distance of a race: the fewest laps that make more than 305 km, as a Grand Prix's is set. */
|
|
11
|
+
export const RACE_DISTANCE = 305000;
|
|
12
|
+
/** The laps of a full race distance on a circuit: 50 at Las Vegas. */
|
|
13
|
+
export const raceLaps = (circuit) => Math.ceil(RACE_DISTANCE / circuit.length.value);
|
|
14
|
+
/** A small generator of numbers in [0, 1) from a seed (mulberry32: whole-number arithmetic only, the same in every engine). */
|
|
15
|
+
function seeded(seed) {
|
|
16
|
+
let a = (Math.trunc(seed) ^ 0x5bd1e995) >>> 0;
|
|
17
|
+
return () => {
|
|
18
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
19
|
+
let t = a;
|
|
20
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
21
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
22
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** The model's own figures: a car's pace in shares of a lap, and the gaps it keeps. */
|
|
26
|
+
const MODEL = Object.freeze({
|
|
27
|
+
/** A team is this much slower than the one before it, and its cars differ by up to this much. */
|
|
28
|
+
teamStep: 0.0025,
|
|
29
|
+
mateSpread: 0.0016,
|
|
30
|
+
/** A full tank costs this share of a lap, falling to nothing at the flag; a tyre this much more each lap it is used, and faster past `wear` laps. */
|
|
31
|
+
fuel: 0.024,
|
|
32
|
+
tyre: 0.00055,
|
|
33
|
+
wear: 24,
|
|
34
|
+
wornTyre: 0.0016,
|
|
35
|
+
/** A lap's own spread, and the chance of a lap with a mistake in it. */
|
|
36
|
+
lapNoise: 0.0042,
|
|
37
|
+
slip: 0.012,
|
|
38
|
+
slipCost: 0.016,
|
|
39
|
+
/** The tow on a straight within a second of the car in front, and the dirty air in a bend behind it. */
|
|
40
|
+
tow: 1.03,
|
|
41
|
+
dirty: 0.994,
|
|
42
|
+
/** The gap a car keeps behind one it cannot pass: metres, and metres for each metre a second. */
|
|
43
|
+
gap: 10,
|
|
44
|
+
gapPerSpeed: 0.42,
|
|
45
|
+
/** A straight is where the lap's speed is above this share of the top: a car can pass there. */
|
|
46
|
+
straight: 0.86,
|
|
47
|
+
/** The chance a car retires. */
|
|
48
|
+
retire: 0.05,
|
|
49
|
+
/** Seconds in a step of the model. */
|
|
50
|
+
dt: 0.2,
|
|
51
|
+
});
|
|
52
|
+
const mod = (a, n) => ((a % n) + n) % n;
|
|
53
|
+
function plansOf(count, laps, random) {
|
|
54
|
+
return Array.from({ length: count }, (_, i) => {
|
|
55
|
+
const team = Math.floor(i / 2);
|
|
56
|
+
const twoStops = random() < 0.65;
|
|
57
|
+
const pits = twoStops ? [Math.round(laps * (0.26 + random() * 0.1)), Math.round(laps * (0.58 + random() * 0.1))] : [Math.round(laps * (0.42 + random() * 0.16))];
|
|
58
|
+
const stopFor = pits.map(() => 2.2 + random() * 0.9 + (random() < 0.06 ? 2 + random() * 5 : 0));
|
|
59
|
+
const noise = Array.from({ length: laps + 3 }, () => (random() - 0.5) * MODEL.lapNoise + (random() < MODEL.slip ? MODEL.slipCost : 0));
|
|
60
|
+
const outLap = random() < MODEL.retire ? 2 + Math.floor(random() * Math.max(1, laps - 4)) : 0;
|
|
61
|
+
return {
|
|
62
|
+
deficit: team * MODEL.teamStep + random() * MODEL.mateSpread,
|
|
63
|
+
reaction: 0.15 + random() * 0.25,
|
|
64
|
+
pits: laps < 4 ? [] : pits.map((p) => Math.min(laps - 1, Math.max(1, p))),
|
|
65
|
+
stopFor,
|
|
66
|
+
box: 0.3 + team * 0.045,
|
|
67
|
+
noise,
|
|
68
|
+
outLap: laps < 6 ? 0 : outLap,
|
|
69
|
+
outAt: random(),
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const CACHE = new Map();
|
|
74
|
+
/**
|
|
75
|
+
* A race on a circuit, worked out from a seed: made-up cars from the grid to the flag over a full race distance (50 laps of Las Vegas, about an hour and a half), with the gaps, overtakes, pit stops and retirements
|
|
76
|
+
* the model gives. The same options always give the same race; the last few asked for are kept.
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* import { simulateRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
80
|
+
* const race = simulateRace({ circuit: "las-vegas", laps: 5, cars: 6, seed: 7 });
|
|
81
|
+
* console.log(race.laps, race.cars.length, race.flag > 5 * 90 && race.flag < 5 * 110);
|
|
82
|
+
* // 5 6 true
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function simulateRace(options) {
|
|
86
|
+
const circuit = circuitOf(options.circuit);
|
|
87
|
+
const seed = Number.isFinite(options.seed) ? Math.trunc(options.seed) : 1;
|
|
88
|
+
const laps = Number.isFinite(options.laps) ? Math.min(200, Math.max(1, Math.trunc(options.laps))) : raceLaps(circuit);
|
|
89
|
+
const count = Number.isFinite(options.cars) ? Math.min(RACE_CARS_MAX, Math.max(RACE_CARS_MIN, Math.trunc(options.cars))) : RACE_CARS_MAX;
|
|
90
|
+
const key = `${circuit.id}|${seed}|${laps}|${count}`;
|
|
91
|
+
const kept = CACHE.get(key);
|
|
92
|
+
if (kept !== undefined)
|
|
93
|
+
return kept;
|
|
94
|
+
const race = run(circuit, seed, laps, count);
|
|
95
|
+
CACHE.set(key, race);
|
|
96
|
+
if (CACHE.size > 4)
|
|
97
|
+
CACHE.delete(CACHE.keys().next().value);
|
|
98
|
+
return race;
|
|
99
|
+
}
|
|
100
|
+
function run(circuit, seed, laps, count) {
|
|
101
|
+
const plan = circuitPlan(circuit);
|
|
102
|
+
const pace = paceProfile(plan);
|
|
103
|
+
const L = pace.length;
|
|
104
|
+
const lineAlong = plan.finish.along / FT;
|
|
105
|
+
const startAlong = plan.start.along / FT;
|
|
106
|
+
const entry = nearestOn(plan.lap, plan.pitLane[0], true).along / FT;
|
|
107
|
+
const exit = nearestOn(plan.lap, plan.pitLane[plan.pitLane.length - 1], true).along / FT;
|
|
108
|
+
const pitLength = lineLength(plan.pitLane, false) / FT;
|
|
109
|
+
const bypass = mod(exit - entry, L);
|
|
110
|
+
const hasPits = pitLength > 20 && bypass > 20;
|
|
111
|
+
const random = seeded(seed);
|
|
112
|
+
const plans = plansOf(count, laps, random);
|
|
113
|
+
// The grid: the order a lap of qualifying gives, each slot eight metres behind the one before it, the first on the start line.
|
|
114
|
+
const order = plans.map((p, i) => ({ i, q: p.deficit + random() * 0.003 })).sort((a, b) => a.q - b.q).map((x) => x.i);
|
|
115
|
+
const gridOf = new Array(count);
|
|
116
|
+
order.forEach((car, place) => (gridOf[car] = place));
|
|
117
|
+
// A lap is done at each multiple of the lap's length; the start line lies `ahead` metres past the timing line (a first lap a little short or long, as a grid makes it).
|
|
118
|
+
let ahead = mod(startAlong - lineAlong, L);
|
|
119
|
+
if (ahead > L / 2)
|
|
120
|
+
ahead -= L;
|
|
121
|
+
const cars = plans.map((_, i) => {
|
|
122
|
+
const driver = RACE_DRIVERS[i];
|
|
123
|
+
return { id: driver.code.toLowerCase(), driver, team: RACE_TEAMS.find((t) => t.id === driver.team), grid: gridOf[i] + 1 };
|
|
124
|
+
});
|
|
125
|
+
const dt = MODEL.dt;
|
|
126
|
+
const sample = 1;
|
|
127
|
+
const every = Math.round(sample / dt);
|
|
128
|
+
const cap = laps * pace.lap * 1.7 + 600;
|
|
129
|
+
const size = Math.ceil(cap / sample) + 2;
|
|
130
|
+
const distance = cars.map(() => new Float64Array(size));
|
|
131
|
+
const pit = cars.map(() => new Float32Array(size).fill(Number.NaN));
|
|
132
|
+
const crossings = cars.map(() => []);
|
|
133
|
+
const stops = cars.map(() => []);
|
|
134
|
+
const finish = cars.map(() => undefined);
|
|
135
|
+
const out = cars.map(() => undefined);
|
|
136
|
+
const s = cars.map((_, i) => ahead - 8 * gridOf[i]);
|
|
137
|
+
const v = cars.map(() => 0);
|
|
138
|
+
const mode = cars.map(() => "track");
|
|
139
|
+
const p = cars.map(() => 0);
|
|
140
|
+
const held = cars.map(() => 0);
|
|
141
|
+
const entered = cars.map(() => 0);
|
|
142
|
+
const pitIn = cars.map(() => 0);
|
|
143
|
+
const stopped = cars.map(() => false);
|
|
144
|
+
const age = cars.map(() => 0);
|
|
145
|
+
let flag = Number.NaN;
|
|
146
|
+
let quiet = Number.NaN;
|
|
147
|
+
const record = (j) => {
|
|
148
|
+
for (let c = 0; c < count; c += 1) {
|
|
149
|
+
distance[c][j] = s[c];
|
|
150
|
+
pit[c][j] = mode[c] === "pit" ? p[c] : Number.NaN;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
record(0);
|
|
154
|
+
let steps = 0;
|
|
155
|
+
let t = 0;
|
|
156
|
+
while (t < cap) {
|
|
157
|
+
steps += 1;
|
|
158
|
+
t = steps * dt;
|
|
159
|
+
for (let c = 0; c < count; c += 1) {
|
|
160
|
+
const plan = plans[c];
|
|
161
|
+
const before = s[c];
|
|
162
|
+
const along = lineAlong + s[c];
|
|
163
|
+
if (mode[c] === "out")
|
|
164
|
+
continue;
|
|
165
|
+
if (mode[c] === "stopping") {
|
|
166
|
+
v[c] = Math.max(0, v[c] - 9 * dt);
|
|
167
|
+
s[c] += v[c] * dt;
|
|
168
|
+
if (v[c] === 0)
|
|
169
|
+
mode[c] = "out";
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (mode[c] === "pit") {
|
|
173
|
+
if (!stopped[c] && p[c] >= plan.box * pitLength) {
|
|
174
|
+
if (held[c] === 0)
|
|
175
|
+
held[c] = plan.stopFor[stops[c].length];
|
|
176
|
+
held[c] = Math.max(0, held[c] - dt);
|
|
177
|
+
v[c] = 0;
|
|
178
|
+
if (held[c] === 0)
|
|
179
|
+
stopped[c] = true;
|
|
180
|
+
else
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
v[c] = v[c] > RACE_PACE.pitLimit ? Math.max(RACE_PACE.pitLimit, v[c] - RACE_PACE.brake * dt) : Math.min(RACE_PACE.pitLimit, v[c] + pullAt(v[c]) * dt);
|
|
184
|
+
p[c] += v[c] * dt;
|
|
185
|
+
s[c] = entered[c] + (Math.min(p[c], pitLength) / pitLength) * bypass;
|
|
186
|
+
if (p[c] >= pitLength) {
|
|
187
|
+
mode[c] = "track";
|
|
188
|
+
stops[c].push({ lap: Math.floor(entered[c] / L) + 1, in: pitIn[c], out: t, stop: plan.stopFor[stops[c].length] });
|
|
189
|
+
age[c] = 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (t >= plan.reaction) {
|
|
193
|
+
const done = Math.max(0, Math.floor(s[c] / L));
|
|
194
|
+
const cool = mode[c] === "done";
|
|
195
|
+
let target = speedAt(pace, along);
|
|
196
|
+
if (cool)
|
|
197
|
+
target *= 0.55;
|
|
198
|
+
else {
|
|
199
|
+
const tyre = MODEL.tyre * age[c] + Math.max(0, age[c] - MODEL.wear) * MODEL.wornTyre;
|
|
200
|
+
const fuel = MODEL.fuel * (1 - Math.min(done, laps) / laps);
|
|
201
|
+
target /= 1 + plan.deficit + fuel + tyre + plan.noise[Math.min(done, plan.noise.length - 1)];
|
|
202
|
+
// The car in front on the track: the tow and the dirty air behind it, and the gap kept when it cannot be passed here.
|
|
203
|
+
let gap = Infinity;
|
|
204
|
+
let other = -1;
|
|
205
|
+
for (let o = 0; o < count; o += 1) {
|
|
206
|
+
if (o === c || mode[o] === "pit" || mode[o] === "out" || mode[o] === "stopping")
|
|
207
|
+
continue;
|
|
208
|
+
const d = mod(s[o] - s[c], L);
|
|
209
|
+
if (d > 0 && d < gap)
|
|
210
|
+
[gap, other] = [d, o];
|
|
211
|
+
}
|
|
212
|
+
if (other >= 0) {
|
|
213
|
+
const base = speedAt(pace, along);
|
|
214
|
+
const seconds = gap / Math.max(v[c], 10);
|
|
215
|
+
const straight = base > MODEL.straight * RACE_PACE.top;
|
|
216
|
+
if (seconds < 1 && straight)
|
|
217
|
+
target *= MODEL.tow;
|
|
218
|
+
else if (seconds < 1.2 && base < 0.6 * RACE_PACE.top)
|
|
219
|
+
target *= MODEL.dirty;
|
|
220
|
+
// A car a lap or more behind lets the leaders by anywhere; one that is racing is passed on a straight, by a car that is quicker there.
|
|
221
|
+
const lapping = s[c] - s[other] > L / 2;
|
|
222
|
+
const keep = MODEL.gap + MODEL.gapPerSpeed * v[c];
|
|
223
|
+
if (!lapping && mode[other] !== "done" && gap < keep && !(straight && target > v[other] * 1.02))
|
|
224
|
+
target = Math.min(target, v[other] + (gap - MODEL.gap) / 1.5);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
target = Math.max(target, 0);
|
|
228
|
+
v[c] = target < v[c] ? Math.max(target, v[c] - RACE_PACE.brake * dt) : Math.min(target, v[c] + pullAt(v[c]) * dt);
|
|
229
|
+
s[c] += v[c] * dt;
|
|
230
|
+
// The pit lane: taken on a lap the strategy picks, where it leaves the lap.
|
|
231
|
+
const nextStop = plans[c].pits[stops[c].length];
|
|
232
|
+
const passedEntry = Math.floor((before - (entry - lineAlong)) / L) < Math.floor((s[c] - (entry - lineAlong)) / L);
|
|
233
|
+
if (!cool && hasPits && passedEntry && nextStop !== undefined && Math.floor(s[c] / L) + 1 >= nextStop) {
|
|
234
|
+
const at = before + mod(entry - lineAlong - before, L);
|
|
235
|
+
mode[c] = "pit";
|
|
236
|
+
entered[c] = at;
|
|
237
|
+
p[c] = s[c] - at;
|
|
238
|
+
stopped[c] = false;
|
|
239
|
+
held[c] = 0;
|
|
240
|
+
pitIn[c] = t;
|
|
241
|
+
}
|
|
242
|
+
// A retirement: on its lap, at its place round it, the car slows to a stop beside the track.
|
|
243
|
+
if (!cool && plan.outLap > 0 && done + 1 === plan.outLap && mod(s[c], L) >= plan.outAt * L && out[c] === undefined && mode[c] === "track") {
|
|
244
|
+
mode[c] = "stopping";
|
|
245
|
+
out[c] = t;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// The timing line: a lap done, the flag, and the finish.
|
|
249
|
+
const k = Math.floor(s[c] / L);
|
|
250
|
+
if (k >= 1 && k > Math.floor(before / L) && mode[c] !== "done") {
|
|
251
|
+
const at = t - (dt * (s[c] - k * L)) / Math.max(s[c] - before, 1e-9);
|
|
252
|
+
crossings[c].push(at);
|
|
253
|
+
if (mode[c] === "track")
|
|
254
|
+
age[c] += 1;
|
|
255
|
+
if (Number.isNaN(flag) && k >= laps)
|
|
256
|
+
flag = at;
|
|
257
|
+
if (!Number.isNaN(flag) && at >= flag) {
|
|
258
|
+
finish[c] = at;
|
|
259
|
+
if (mode[c] === "track")
|
|
260
|
+
mode[c] = "done";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (steps % every === 0)
|
|
265
|
+
record(steps / every);
|
|
266
|
+
if (Number.isNaN(quiet) && mode.every((m, c) => m === "done" || m === "out" || (finish[c] !== undefined && m !== "pit")))
|
|
267
|
+
quiet = t;
|
|
268
|
+
if (!Number.isNaN(quiet) && t >= quiet + 20)
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const last = Math.floor(t / sample);
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
circuit,
|
|
274
|
+
plan,
|
|
275
|
+
seed,
|
|
276
|
+
laps,
|
|
277
|
+
cars: Object.freeze(cars),
|
|
278
|
+
lapLength: L,
|
|
279
|
+
lineAlong,
|
|
280
|
+
pitLane: { entry, exit, length: pitLength },
|
|
281
|
+
pace,
|
|
282
|
+
sample,
|
|
283
|
+
flag: Number.isNaN(flag) ? last * sample : flag,
|
|
284
|
+
duration: last * sample,
|
|
285
|
+
distance: distance.map((d) => d.slice(0, last + 1)),
|
|
286
|
+
pit: pit.map((d) => d.slice(0, last + 1)),
|
|
287
|
+
crossings,
|
|
288
|
+
stops,
|
|
289
|
+
finish,
|
|
290
|
+
out,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Pt } from "./crowd.ts";
|
|
2
|
+
import type { CircuitRace, RaceCar } from "./circuits.race.ts";
|
|
3
|
+
/** What a car is doing. */
|
|
4
|
+
export type RaceStatus = "grid" | "running" | "pit" | "out" | "finished";
|
|
5
|
+
/** One line of the timing board. */
|
|
6
|
+
export interface RaceStanding {
|
|
7
|
+
car: RaceCar;
|
|
8
|
+
/** Its place in the race's `cars`. */
|
|
9
|
+
index: number;
|
|
10
|
+
/** 1 for the leader. */
|
|
11
|
+
position: number;
|
|
12
|
+
status: RaceStatus;
|
|
13
|
+
/** Laps done. */
|
|
14
|
+
laps: number;
|
|
15
|
+
/** Seconds behind the leader (undefined for the leader, and for a car a lap or more down), and laps down. */
|
|
16
|
+
gap?: number;
|
|
17
|
+
lapsDown: number;
|
|
18
|
+
/** The same to the car in front. */
|
|
19
|
+
interval?: number;
|
|
20
|
+
intervalLaps: number;
|
|
21
|
+
/** The last lap's time and the car's best, in seconds. */
|
|
22
|
+
last?: number;
|
|
23
|
+
best?: number;
|
|
24
|
+
/** Pit stops made. */
|
|
25
|
+
stops: number;
|
|
26
|
+
/** Where it is on the drawing, in its frame (feet), and its race distance in metres. */
|
|
27
|
+
at: Pt;
|
|
28
|
+
distance: number;
|
|
29
|
+
}
|
|
30
|
+
/** A moment of the race. */
|
|
31
|
+
export interface RaceMoment {
|
|
32
|
+
/** Seconds since the start. */
|
|
33
|
+
t: number;
|
|
34
|
+
/** The lap the leader is on (1 to `laps`), and the laps of the race. */
|
|
35
|
+
lap: number;
|
|
36
|
+
laps: number;
|
|
37
|
+
/** Whether the chequered flag has fallen, and whether the race is over (every car home). */
|
|
38
|
+
flag: boolean;
|
|
39
|
+
over: boolean;
|
|
40
|
+
/** The standings, leader first. */
|
|
41
|
+
standings: RaceStanding[];
|
|
42
|
+
/** The fastest lap so far: the car's place in `cars`, the lap and its time. */
|
|
43
|
+
fastest?: {
|
|
44
|
+
index: number;
|
|
45
|
+
lap: number;
|
|
46
|
+
time: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** A car's race distance at a time (metres from the timing line), and its distance along the pit lane, or NaN on the track. */
|
|
50
|
+
export declare function carAt(race: CircuitRace, index: number, t: number): {
|
|
51
|
+
distance: number;
|
|
52
|
+
pit: number;
|
|
53
|
+
};
|
|
54
|
+
/** Where a car is on the drawing at a time, in the drawing's frame (feet): on the lap, or in the pit lane. */
|
|
55
|
+
export declare function racePoint(race: CircuitRace, index: number, t: number): Pt;
|
|
56
|
+
/**
|
|
57
|
+
* A moment of a race: each car's place on the drawing and its line of the timing board (position, laps, the gap to the leader and to the car in front, the last and best laps, the stops), the lap the leader is on and
|
|
58
|
+
* the fastest lap so far.
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { raceAt, simulateRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
62
|
+
* const race = simulateRace({ circuit: "las-vegas", laps: 3, cars: 4 });
|
|
63
|
+
* const now = raceAt(race, 150);
|
|
64
|
+
* console.log(now.lap, now.standings.length, now.standings[0].position, now.flag);
|
|
65
|
+
* // 2 4 1 false
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export declare function raceAt(race: CircuitRace, time: number): RaceMoment;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { linePoint, ringLengths, ringPoint } from "./circuits.pace.js";
|
|
2
|
+
import { FEET_PER_METRE as FT } from "./geometry.js";
|
|
3
|
+
/** The lengths of the lap and the pit lane on the drawing, worked out once for a race. */
|
|
4
|
+
const LENGTHS = new WeakMap();
|
|
5
|
+
const lengthsOf = (race) => {
|
|
6
|
+
let kept = LENGTHS.get(race);
|
|
7
|
+
if (kept === undefined) {
|
|
8
|
+
kept = { lap: ringLengths(race.plan.lap), pit: ringLengths(race.plan.pitLane) };
|
|
9
|
+
LENGTHS.set(race, kept);
|
|
10
|
+
}
|
|
11
|
+
return kept;
|
|
12
|
+
};
|
|
13
|
+
/** A sample's index and the share of the way to the next, at a time. */
|
|
14
|
+
const slot = (race, t) => {
|
|
15
|
+
const last = race.distance[0].length - 1;
|
|
16
|
+
const x = Math.min(Math.max(t / race.sample, 0), last);
|
|
17
|
+
const j = Math.min(Math.floor(x), Math.max(0, last - 1));
|
|
18
|
+
return [j, x - j];
|
|
19
|
+
};
|
|
20
|
+
/** A car's race distance at a time (metres from the timing line), and its distance along the pit lane, or NaN on the track. */
|
|
21
|
+
export function carAt(race, index, t) {
|
|
22
|
+
const [j, f] = slot(race, t);
|
|
23
|
+
const d = race.distance[index];
|
|
24
|
+
const p = race.pit[index];
|
|
25
|
+
const k = Math.min(j + 1, d.length - 1);
|
|
26
|
+
const a = p[j];
|
|
27
|
+
const b = p[k];
|
|
28
|
+
const pit = Number.isNaN(a) && Number.isNaN(b) ? Number.NaN : Number.isNaN(a) ? (f >= 0.5 ? b : Number.NaN) : Number.isNaN(b) ? (f < 0.5 ? a : Number.NaN) : a + f * (b - a);
|
|
29
|
+
return { distance: d[j] + f * (d[k] - d[j]), pit };
|
|
30
|
+
}
|
|
31
|
+
/** Where a car is on the drawing at a time, in the drawing's frame (feet): on the lap, or in the pit lane. */
|
|
32
|
+
export function racePoint(race, index, t) {
|
|
33
|
+
const { distance, pit } = carAt(race, index, t);
|
|
34
|
+
const lengths = lengthsOf(race);
|
|
35
|
+
if (!Number.isNaN(pit))
|
|
36
|
+
return linePoint(race.plan.pitLane, lengths.pit, pit * FT);
|
|
37
|
+
return ringPoint(race.plan.lap, lengths.lap, (race.lineAlong + distance) * FT);
|
|
38
|
+
}
|
|
39
|
+
/** How many of a sorted list are at or before `t`. */
|
|
40
|
+
const countTo = (list, t) => {
|
|
41
|
+
let lo = 0;
|
|
42
|
+
let hi = list.length;
|
|
43
|
+
while (lo < hi) {
|
|
44
|
+
const mid = (lo + hi) >> 1;
|
|
45
|
+
if (list[mid] <= t)
|
|
46
|
+
lo = mid + 1;
|
|
47
|
+
else
|
|
48
|
+
hi = mid;
|
|
49
|
+
}
|
|
50
|
+
return lo;
|
|
51
|
+
};
|
|
52
|
+
/** The time a car's race distance first reached `distance`, from its samples (they never go back), or undefined if it never did. */
|
|
53
|
+
function timeAt(race, index, distance) {
|
|
54
|
+
const d = race.distance[index];
|
|
55
|
+
if (d[d.length - 1] < distance)
|
|
56
|
+
return undefined;
|
|
57
|
+
if (d[0] >= distance)
|
|
58
|
+
return 0;
|
|
59
|
+
let lo = 0;
|
|
60
|
+
let hi = d.length - 1;
|
|
61
|
+
while (hi - lo > 1) {
|
|
62
|
+
const mid = (lo + hi) >> 1;
|
|
63
|
+
if (d[mid] < distance)
|
|
64
|
+
lo = mid;
|
|
65
|
+
else
|
|
66
|
+
hi = mid;
|
|
67
|
+
}
|
|
68
|
+
const span = d[hi] - d[lo];
|
|
69
|
+
return (lo + (span > 0 ? (distance - d[lo]) / span : 0)) * race.sample;
|
|
70
|
+
}
|
|
71
|
+
/** The gap from one car to another ahead of it at a time: seconds, or whole laps when it is a lap or more behind. */
|
|
72
|
+
function gapTo(race, ahead, behind, t) {
|
|
73
|
+
const L = race.lapLength;
|
|
74
|
+
if (ahead.finished !== undefined && behind.finished !== undefined) {
|
|
75
|
+
const laps = race.crossings[ahead.index].length - race.crossings[behind.index].length;
|
|
76
|
+
return laps > 0 ? { laps } : { seconds: behind.finished - ahead.finished, laps: 0 };
|
|
77
|
+
}
|
|
78
|
+
const laps = Math.floor((ahead.distance - behind.distance) / L);
|
|
79
|
+
if (laps >= 1)
|
|
80
|
+
return { laps };
|
|
81
|
+
const when = timeAt(race, ahead.index, behind.distance);
|
|
82
|
+
return { seconds: when === undefined ? undefined : Math.max(0, (behind.finished ?? t) - when), laps: 0 };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A moment of a race: each car's place on the drawing and its line of the timing board (position, laps, the gap to the leader and to the car in front, the last and best laps, the stops), the lap the leader is on and
|
|
86
|
+
* the fastest lap so far.
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* import { raceAt, simulateRace } from "@johnmorrisdotca/kyogijo-stadiums/circuits";
|
|
90
|
+
* const race = simulateRace({ circuit: "las-vegas", laps: 3, cars: 4 });
|
|
91
|
+
* const now = raceAt(race, 150);
|
|
92
|
+
* console.log(now.lap, now.standings.length, now.standings[0].position, now.flag);
|
|
93
|
+
* // 2 4 1 false
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function raceAt(race, time) {
|
|
97
|
+
const t = Math.min(Math.max(Number.isFinite(time) ? time : 0, 0), race.duration);
|
|
98
|
+
const lines = race.cars.map((car, index) => {
|
|
99
|
+
const { distance, pit } = carAt(race, index, t);
|
|
100
|
+
const laps = countTo(race.crossings[index], t);
|
|
101
|
+
const finished = race.finish[index] !== undefined && race.finish[index] <= t ? race.finish[index] : undefined;
|
|
102
|
+
const outAt = race.out[index];
|
|
103
|
+
const status = finished !== undefined ? "finished" : outAt !== undefined && outAt <= t ? "out" : !Number.isNaN(pit) ? "pit" : t <= 0 ? "grid" : "running";
|
|
104
|
+
// A car that has finished ranks by the laps it did and when it did them; one that retired ranks after every car still in the race.
|
|
105
|
+
const rank = status === "finished" ? laps * race.lapLength + 1e-3 * (race.duration - finished) : status === "out" ? distance - 1e9 : distance;
|
|
106
|
+
return { car, index, distance, laps, finished, status, rank, at: racePoint(race, index, t) };
|
|
107
|
+
});
|
|
108
|
+
lines.sort((a, b) => b.rank - a.rank || a.index - b.index);
|
|
109
|
+
const leader = lines[0];
|
|
110
|
+
let fastest;
|
|
111
|
+
const standings = lines.map((line, i) => {
|
|
112
|
+
const crossed = race.crossings[line.index];
|
|
113
|
+
let best;
|
|
114
|
+
for (let k = 0; k < line.laps; k += 1) {
|
|
115
|
+
const lapTime = crossed[k] - (k === 0 ? 0 : crossed[k - 1]);
|
|
116
|
+
// The first lap starts from the grid: it is not a flying lap.
|
|
117
|
+
if (k === 0)
|
|
118
|
+
continue;
|
|
119
|
+
if (best === undefined || lapTime < best)
|
|
120
|
+
best = lapTime;
|
|
121
|
+
if (fastest === undefined || lapTime < fastest.time)
|
|
122
|
+
fastest = { index: line.index, lap: k + 1, time: lapTime };
|
|
123
|
+
}
|
|
124
|
+
// On the grid, and for the leader and a car that retired, there is no gap to give.
|
|
125
|
+
const none = i === 0 || line.status === "out" || line.status === "grid";
|
|
126
|
+
const toLeader = none ? { laps: 0 } : gapTo(race, leader, line, t);
|
|
127
|
+
const toNext = none ? { laps: 0 } : gapTo(race, lines[i - 1], line, t);
|
|
128
|
+
return {
|
|
129
|
+
car: line.car,
|
|
130
|
+
index: line.index,
|
|
131
|
+
position: i + 1,
|
|
132
|
+
status: line.status,
|
|
133
|
+
laps: line.laps,
|
|
134
|
+
...(toLeader.seconds === undefined ? {} : { gap: toLeader.seconds }),
|
|
135
|
+
lapsDown: toLeader.laps,
|
|
136
|
+
...(toNext.seconds === undefined ? {} : { interval: toNext.seconds }),
|
|
137
|
+
intervalLaps: toNext.laps,
|
|
138
|
+
...(line.laps === 0 ? {} : { last: crossed[line.laps - 1] - (line.laps === 1 ? 0 : crossed[line.laps - 2]) }),
|
|
139
|
+
...(best === undefined ? {} : { best }),
|
|
140
|
+
stops: race.stops[line.index].filter((s) => s.in <= t).length,
|
|
141
|
+
at: line.at,
|
|
142
|
+
distance: line.distance,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
const lap = Math.min(race.laps, Math.max(1, (leader?.laps ?? 0) + 1));
|
|
146
|
+
return { t, lap, laps: race.laps, flag: t >= race.flag, over: t >= race.duration, standings, ...(fastest === undefined ? {} : { fastest }) };
|
|
147
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Language } from "./types.ts";
|
|
2
|
+
/** What a race's pictures, controls and board say. */
|
|
3
|
+
export interface RaceWords {
|
|
4
|
+
/** The line that says what this is: a model, not a real race. */
|
|
5
|
+
simulated: string;
|
|
6
|
+
/** The race clock: the lap the leader is on, and the line under it once the flag has fallen. */
|
|
7
|
+
lap: string;
|
|
8
|
+
finalLap: string;
|
|
9
|
+
flag: string;
|
|
10
|
+
grid: string;
|
|
11
|
+
/** The time since the start, for a screen reader. */
|
|
12
|
+
elapsed: string;
|
|
13
|
+
/** The controls. */
|
|
14
|
+
group: string;
|
|
15
|
+
play: string;
|
|
16
|
+
pause: string;
|
|
17
|
+
nextLap: string;
|
|
18
|
+
speed: string;
|
|
19
|
+
speedValue: string;
|
|
20
|
+
position: string;
|
|
21
|
+
positionValue: string;
|
|
22
|
+
/** The board: its caption, its heads, and what a line says. */
|
|
23
|
+
board: {
|
|
24
|
+
caption: string;
|
|
25
|
+
position: string;
|
|
26
|
+
driver: string;
|
|
27
|
+
team: string;
|
|
28
|
+
gap: string;
|
|
29
|
+
interval: string;
|
|
30
|
+
last: string;
|
|
31
|
+
best: string;
|
|
32
|
+
stops: string;
|
|
33
|
+
leader: string;
|
|
34
|
+
lapDown: string;
|
|
35
|
+
lapsDown: string;
|
|
36
|
+
pit: string;
|
|
37
|
+
out: string;
|
|
38
|
+
finished: string;
|
|
39
|
+
fastest: string;
|
|
40
|
+
};
|
|
41
|
+
/** A car on the drawing, for a screen reader. */
|
|
42
|
+
car: string;
|
|
43
|
+
/** What is said aloud as the race goes. */
|
|
44
|
+
leads: string;
|
|
45
|
+
wins: string;
|
|
46
|
+
/** The drawing's description, added to the circuit's. */
|
|
47
|
+
description: string;
|
|
48
|
+
}
|
|
49
|
+
export declare const RACE_WORDS: Readonly<Record<Language, RaceWords>>;
|