Files
Crystal/src/main.ts
T
Sébastien QUEROLandClaude Opus 5.5 796fe8f6d5
Checks / web (push) Canceled after 0s
Open Route 29 and Cherrygrove City
- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings.
- Run item balls through FindItemInBallScript and fruit trees through
  FruitTreeScript, with a native daily reset of the daily engine flags.
- Port the Pokémon Center nurse (HealParty with the PP Up clamp,
  CheckPokerus, phone-call checks) and record the whiteout spawn on
  entering a Pokémon Center, as EnterMapWarp does.
- Port standard Poké Marts with keyboard, D-pad and pointer menus.
- Add the recoil battle command (Struggle) and VAR_BOXSPACE.
- Save fruit trees, the daily reset day and the last spawn map; older
  version-3 backups migrate by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:25:02 +02:00

734 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs";
import { NativeRuntime } from "./game/runtime";
import { LocalMapView } from "./game/rendering/minimap";
import type { Choice, GameWorld } from "./game/world/world";
import { drawTile } from "./game/rendering/pixels";
import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types";
import type { Button } from "./input/types";
import { NICKNAME_PATTERN } from "./game/party/pokemon";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
import { validateRom } from "./rom/validate";
import { BrowserSaveRepository, encodeSave } from "./storage/saves";
import { acquireSessionLock } from "./platform/session-lock";
const root = document.querySelector<HTMLElement>("#app")!;
renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const mapView = new LocalMapView(
get<HTMLCanvasElement>("minimap"),
get<HTMLCanvasElement>("world-map"),
);
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
function showWorld(world: GameWorld) {
mapView.update(world);
get("map-location").textContent =
world.map.name === world.map.location
? world.map.name
: `${world.map.name} · ${world.map.location}`;
get("map-position").textContent =
`You are at ${world.x}, ${world.y}. Red marks your position.`;
get("minimap-position").textContent = `${world.x}, ${world.y}`;
get("map-empty").hidden = true;
get<HTMLInputElement>("player-name").value = world.name;
get<HTMLSelectElement>("player-avatar").value = world.avatar;
get("location").textContent = world.map.name;
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
showBattle(world);
const dialogue = world.dialogue;
showPicture(world);
// A page without text is a button wait while a picture is shown.
const pictureWait = Boolean(
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
);
const mart = dialogue?.choice && martChoice(dialogue.choice);
get("native-dialogue").hidden = !dialogue || pictureWait || Boolean(mart);
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
dialogue && dialogue.pages.length > 1
? `${dialogue.index + 1} / ${dialogue.pages.length}`
: "";
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
const choice = last && !mart ? dialogue.choice : undefined;
// Timed pages advance themselves; choices replace Continue.
get("dialogue-next").hidden = Boolean(
choice || mart || (last && dialogue.holdTicks !== undefined),
);
showChoice(choice);
showMart(world, mart);
// Coordinates provide a stable accessible description and a test observation
// point, without exposing mutable game state or a production debug API.
canvas.dataset.map = world.mapId;
canvas.dataset.x = String(world.x);
canvas.dataset.y = String(world.y);
canvas.dataset.activity = world.activity;
canvas.setAttribute(
"aria-label",
`${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`,
);
}
function showChoice(choice: Choice | undefined) {
const container = get("dialogue-choice");
container.hidden = !choice;
const key = choice ? JSON.stringify(choice) : "";
if (container.dataset.choice === key) return;
container.dataset.choice = key;
const button = (
label: string,
pressed: boolean | undefined,
action: () => void,
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (pressed !== undefined)
element.setAttribute("aria-pressed", String(pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
if (!choice) container.replaceChildren();
else if (choice.kind === "yesno")
container.replaceChildren(
button("YES", choice.selected === 1, () => runtime.choose(1)),
button("NO", choice.selected === 0, () => runtime.choose(0)),
);
else if (choice.kind === "weekday") {
// The original weekday box: Up for the next day, Down for the previous.
const day = document.createElement("output");
day.textContent = choice.names[choice.selected];
day.setAttribute("aria-live", "polite");
container.replaceChildren(
button("▼", undefined, () => {
runtime.world?.moveCursor("down");
if (runtime.world) showWorld(runtime.world);
}),
day,
button("▲", undefined, () => {
runtime.world?.moveCursor("up");
if (runtime.world) showWorld(runtime.world);
}),
button("OK", true, () => runtime.choose(choice.selected)),
);
container.firstElementChild?.setAttribute("aria-label", "Previous day");
container.children[2]?.setAttribute("aria-label", "Next day");
} else if (choice.kind === "name") {
// The naming screen: an empty name keeps the current one.
const input = document.createElement("input");
input.maxLength = choice.max;
input.placeholder = choice.current;
input.setAttribute("aria-label", `Nickname for ${choice.current}`);
input.autocomplete = "off";
input.spellcheck = false;
const submit = () => {
const name = input.value.trim();
if (name && !NICKNAME_PATTERN.test(name)) {
input.setCustomValidity(
"Use letters, digits, spaces and simple punctuation.",
);
input.reportValidity();
return;
}
runtime.choose(name);
canvas.focus({ preventScroll: true });
};
input.addEventListener("input", () => input.setCustomValidity(""));
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") submit();
});
const ok = document.createElement("button");
ok.type = "button";
ok.textContent = "OK";
ok.addEventListener("click", () => {
if (session.state === "playing") submit();
});
container.replaceChildren(input, ok);
queueMicrotask(() => input.focus({ preventScroll: true }));
}
}
type MartChoice = Extract<
Choice,
{ kind: "martmenu" | "martlist" | "martquantity" }
>;
function martChoice(choice: Choice): MartChoice | undefined {
return choice.kind === "martmenu" ||
choice.kind === "martlist" ||
choice.kind === "martquantity"
? choice
: undefined;
}
let martMenuKey = "";
/** The mart's BUY/SELL/QUIT box, item list or quantity stepper, plus a
* money display. Follows the battle menu's button-grid style. */
function showMart(world: GameWorld, mart: MartChoice | undefined) {
get("mart").hidden = !mart;
if (!mart) {
martMenuKey = "";
return;
}
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
get("mart-prompt").textContent = mart.kind === "martlist" ? "" : mart.prompt;
const key = JSON.stringify(mart);
if (key === martMenuKey) return;
martMenuKey = key;
const menu = get("mart-menu");
const button = (
label: string,
action: () => void,
options: { detail?: string; selected?: boolean; wide?: boolean } = {},
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (options.detail) {
const small = document.createElement("small");
small.textContent = options.detail;
element.append(small);
}
if (options.selected) element.classList.add("battle-selected");
if (options.wide) element.classList.add("wide");
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
if (mart.kind === "martmenu") {
menu.replaceChildren(
...(["BUY", "SELL", "QUIT"] as const).map((label, index) =>
button(label, () => runtime.choose(index), {
selected: mart.selected === index,
}),
),
);
return;
}
if (mart.kind === "martlist") {
const buttons = mart.entries.map((entry, index) =>
button(entry.name, () => runtime.choose(index), {
detail:
mart.mode === "sell" ? `have ${entry.owned}` : `¥${entry.price}`,
selected: mart.selected === index,
}),
);
buttons.push(button("BACK", () => runtime.choose(-1), { wide: true }));
menu.replaceChildren(...buttons);
return;
}
// martquantity: a 1-99 stepper (±1 Up/Down, ±10 Left/Right, matching
// BuySellToss_InterpretJoypad), then BUY/SELL and CANCEL.
const total =
mart.mode === "sell"
? Math.floor((mart.price * mart.selected) / 2)
: mart.price * mart.selected;
const stepper = document.createElement("div");
stepper.className = "mart-quantity";
const step = (label: string, aria: string, direction: Direction) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
element.setAttribute("aria-label", aria);
element.addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.world?.moveCursor(direction);
if (runtime.world) showWorld(runtime.world);
canvas.focus({ preventScroll: true });
});
return element;
};
const output = document.createElement("output");
output.textContent = `${mart.name} ×${mart.selected} ¥${total}`;
stepper.append(
step("◀◀", "Minus 10", "left"),
step("▼", "Minus 1", "down"),
output,
step("▲", "Plus 1", "up"),
step("▶▶", "Plus 10", "right"),
);
menu.replaceChildren(
stepper,
button(mart.mode === "sell" ? "SELL" : "BUY", () => runtime.choose(mart.selected), { wide: true }),
button("CANCEL", () => runtime.choose(-1), { wide: true }),
);
}
const STATUS_NAMES: [number, string][] = [
[0x08, "PSN"],
[0x10, "BRN"],
[0x20, "FRZ"],
[0x40, "PAR"],
];
function statusName(status: number) {
if (status & 7) return "SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, palette: Palette, flip = false) {
const context = target.getContext("2d")!;
const pixels = size * 8;
const image = context.createImageData(pixels, pixels);
for (let tile = 0; tile < size * size; tile++)
drawTile(image.data, pixels, pixels, tiles, tile, Math.floor(tile / size) * 8, (tile % size) * 8, palette, { flipX: flip });
context.putImageData(image, 0, 0);
}
const battlePics = { foe: "", player: "" };
let battleMenuKey = "";
let showMoves = false;
let battleCursor = 0;
let battleSelectionKey = "";
function battleButtons() {
return [...get("battle-menu").querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
}
function highlightBattleChoice() {
battleButtons().forEach((button, index) => {
button.classList.toggle("battle-selected", index === battleCursor);
});
}
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
function handleBattleInput(button: Button): boolean {
const world = runtime.world;
if (!world?.battle) return false;
if (button === "b" && showMoves) {
showMoves = false;
return true;
}
const buttons = battleButtons();
if (!buttons.length) return false;
if (button === "a") {
buttons[battleCursor]?.click();
return true;
}
if (["up", "down", "left", "right"].includes(button)) {
const delta = button === "up" || button === "left" ? -1 : 1;
battleCursor = (battleCursor + delta + buttons.length) % buttons.length;
if (world.battle.prompt?.kind === "yesno")
world.battle.selected = battleCursor === 0 ? 1 : 0;
highlightBattleChoice();
return true;
}
return false;
}
/** The battle screen: pictures, HUDs, messages and the current choice. */
function showBattle(world: GameWorld) {
const view = world.battle;
const screen = get("battle");
screen.hidden = !view;
if (!view) {
battlePics.foe = battlePics.player = battleMenuKey = "";
showMoves = false;
battleCursor = 0;
battleSelectionKey = "";
return;
}
const data = world.assets.pokemon;
const { player, enemy } = view.engine;
const foe = enemy.mon,
own = player.mon;
if (foe) {
const key = `${foe.species}:${foe.dvs}`;
if (battlePics.foe !== key) {
battlePics.foe = key;
drawPic(get("battle-foe-pic"), data.frontPic(foe.species), 7, data.palette(foe.species, PokemonData.shiny(foe.dvs)));
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
}
get("battle-foe-name").textContent = foe.nickname;
get("battle-foe-level").textContent = `:L${foe.level}`;
get("battle-foe-status").textContent = statusName(foe.status);
setHp(get("battle-foe-hp"), foe.hp, foe.maxHp);
}
get("battle-player").hidden = !own;
if (own) {
const key = `${own.species}:${own.dvs}`;
if (battlePics.player !== key) {
battlePics.player = key;
drawPic(get("battle-player-pic"), data.backPic(own.species), 6, data.palette(own.species, PokemonData.shiny(own.dvs)));
get("battle-player-pic").setAttribute("aria-label", own.nickname);
}
get("battle-player-name").textContent = own.nickname;
get("battle-player-level").textContent = `:L${own.level}`;
get("battle-player-status").textContent = statusName(own.status);
get("battle-player-hp-text").textContent = `${own.hp}/ ${own.maxHp}`;
setHp(get("battle-player-hp"), own.hp, own.maxHp);
}
get("battle-text").textContent = view.message?.text ?? "";
const prompt = view.prompt;
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}`;
if (selectionKey !== battleSelectionKey) {
battleCursor = 0;
battleSelectionKey = selectionKey;
}
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
const key = JSON.stringify([prompt, view.selected, showMoves, own?.pp, view.message?.prompt]);
if (key === battleMenuKey) return;
battleMenuKey = key;
const menu = get("battle-menu");
const button = (label: string, action: () => void, options: { detail?: string; disabled?: string; wide?: boolean; pressed?: boolean } = {}) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (options.detail) {
const small = document.createElement("small");
small.textContent = options.detail;
element.append(small);
}
if (options.disabled) {
element.disabled = true;
element.title = options.disabled;
}
if (options.wide) element.classList.add("wide");
if (options.pressed !== undefined) element.setAttribute("aria-pressed", String(options.pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
const buttons: HTMLButtonElement[] = [];
if (prompt?.kind === "action" && own) {
if (showMoves) {
own.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(data.moveName(move), () => {
showMoves = false;
runtime.battleAction({ action: "fight", move: index });
}, { detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` }),
);
});
buttons.push(button("BACK", () => {
showMoves = false;
showWorld(world);
}, { wide: true }));
} else {
buttons.push(
button("FIGHT", () => {
showMoves = true;
showWorld(world);
}),
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
button("PACK", () => {}, { disabled: "Using items in battle is not ported yet." }),
button("RUN", () => runtime.battleAction({ action: "run" })),
);
}
} else if (prompt?.kind === "yesno") {
buttons.push(
button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1 }),
button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0 }),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))),
);
buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true }));
} else if (prompt?.kind === "stats") {
prompt.labels.forEach((label, index) =>
buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })),
);
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
} else if (view.message?.prompt) {
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
}
menu.replaceChildren(...buttons);
battleCursor = Math.min(battleCursor, Math.max(0, battleButtons().length - 1));
highlightBattleChoice();
}
function setHp(bar: HTMLElement, hp: number, max: number) {
const fraction = max ? hp / max : 0;
bar.style.width = `${Math.round(fraction * 100)}%`;
bar.classList.toggle("low", fraction <= 0.5 && fraction > 0.2);
bar.classList.toggle("critical", fraction <= 0.2);
}
let shownPicture: number | undefined;
/** Draw a pokepic from the ROM's front picture tiles and palette. */
function showPicture(world: GameWorld) {
const box = get("pokepic");
box.hidden = world.picture === undefined;
if (world.picture === shownPicture) return;
shownPicture = world.picture;
if (world.picture === undefined) return;
const data = world.assets.pokemon;
const picture = get<HTMLCanvasElement>("pokepic-canvas");
const context = picture.getContext("2d")!;
const image = context.createImageData(56, 56);
const tiles = data.frontPic(world.picture),
palette = data.palette(world.picture);
// Picture tiles are stored column by column.
for (let tile = 0; tile < 49; tile++)
drawTile(
image.data,
56,
56,
tiles,
tile,
Math.floor(tile / 7) * 8,
(tile % 7) * 8,
palette,
);
context.putImageData(image, 0, 0);
picture.setAttribute("aria-label", data.speciesName(world.picture));
}
const runtime = new NativeRuntime(
showWorld,
(error) => {
session.state = "error";
controls.release();
showError(error);
update();
},
() => ({
name:
get<HTMLInputElement>("player-name")
.value.trim()
.replace(/[^A-Za-z0-9 -]/g, "")
.slice(0, 7) || "CHRIS",
avatar:
get<HTMLSelectElement>("player-avatar").value === "kris"
? "kris"
: "chris",
}),
handleBattleInput,
);
const session = new GameSession(
runtime,
new BrowserSaveRepository("crystal-native-port"),
);
const input = new InputController((button, pressed) =>
runtime.setButton(button, pressed),
);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy && tabs.active() === "game",
() => {},
);
const ready = () => ["paused", "playing"].includes(session.state);
const tabs = bindTabs(root, () => {
pause();
controls.release();
update();
});
function showError(error: unknown) {
status.textContent =
error instanceof Error
? error.message
: "Something went wrong. Please try again.";
status.classList.add("error");
}
function message(text: string) {
status.textContent = text;
status.classList.remove("error");
}
function update() {
const playing = session.state === "playing";
root.classList.toggle("has-game", ready());
for (const id of ["play", "save", "backup", "import-save", "screen-resume"]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
root
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
.forEach((button) => {
button.disabled = !playing || busy;
});
get<HTMLButtonElement>("play").textContent = playing ? "Pause" : "Play";
get("power-label").textContent = playing
? "PLAYING"
: ready()
? "PAUSED"
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("minimap-position").hidden = !ready();
get("world-map").hidden = !ready();
get("map-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
get<HTMLInputElement>("player-name").disabled = busy || ready();
get<HTMLSelectElement>("player-avatar").disabled = busy || ready();
get<HTMLButtonElement>("dialogue-next").disabled =
busy || session.state !== "playing";
}
async function save() {
if (!ready()) return;
// Capture again after an earlier write, so pausing never drops the last frame.
if (saving) {
await saving.catch(() => {});
return save();
}
saving = session
.save()
.then((time) => {
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
saveStatus.classList.remove("error");
})
.catch((error) => {
saveStatus.textContent =
"Session could not be saved. Export a backup to keep your progress.";
saveStatus.classList.add("error");
throw error;
})
.finally(() => {
saving = undefined;
});
return saving;
}
function pause() {
if (session.state !== "playing") return;
session.pause();
controls.release();
update();
void save().catch(showError);
}
function play() {
if (busy) return;
tabs.select("game");
session.play();
canvas.focus({ preventScroll: true });
message("Press X or A to talk and read. Up and Down change choices.");
update();
}
get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
busy = true;
controls.release();
update();
message("Checking your cartridge locally…");
try {
const rom = await validateRom(file);
if (!lockRelease)
lockRelease = await acquireSessionLock("crystal-native-session");
if (ready()) {
session.pause();
controls.release();
await saving;
await save();
}
const restored = await session.load(rom, canvas);
tabs.select("game");
message(
restored
? "Your saved session is ready. Press Play to continue."
: "Cartridge verified. Press Play to explore your room and New Bark Town.",
);
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("pokepic-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
});
get("dialogue-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
});
get("save").addEventListener("click", () => {
void save().catch(showError);
});
function download(contents: BlobPart, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
get("backup").addEventListener("click", () => {
try {
download(
encodeSave(session.snapshot()),
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message("Session backup exported. Keep it somewhere safe.");
} catch (error) {
showError(error);
}
});
get("import-save").addEventListener("click", () => {
pause();
get<HTMLInputElement>("save-file").click();
});
get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
if (file.size > 2_000_000) {
showError(new Error("This backup is too large."));
return;
}
if (
!window.confirm(
"Replace your current session with this backup? Export your current session first if you want to keep it.",
)
)
return;
busy = true;
update();
try {
await saving;
await session.importSave(await file.text());
message("Backup restored. Press Play to continue.");
saveStatus.textContent = "Imported session saved on this device.";
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("help").addEventListener("click", () => {
pause();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pause();
});
window.addEventListener("pagehide", () => {
pause();
});
setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();