;
+
+const EM_DASH = String.fromCharCode(0x2014);
+const OG_IMAGE_NAME = 'og-poster.jpg';
+
+function diagramsBody(diagrams: Diagram[]): string {
+ const intro =
+ 'Seven diagrams, drawn from one source each and checked into the repository. Select a diagram to open the full size file.
';
+ const sections = diagrams
+ .map(
+ (d) => `
+${escapeHtml(d.title)} #
+
+
+${escapeHtml(d.description)}
+
+ `
+ )
+ .join('\n');
+ return `${intro}\n${sections}`;
+}
+
+function indexBody(): string {
+ const cards = PAGES.map(
+ (page) =>
+ `${escapeHtml(page.title)} ${escapeHtml(page.description)} `
+ ).join('\n');
+ return `Deepmark is a turn based dungeon crawler where a Stellar contract replays every run, so a result or a relic cannot be claimed without playing for it. These pages are built from the markdown in the repository.
+Play the game Verify a run in 60 seconds
+`;
+}
+
+/** Build every file of the docs site, relative to the site root (for example docs/overview/index.html). */
+export function buildSite(): SiteFiles {
+ const files: SiteFiles = new Map();
+ const diagrams = loadDiagrams();
+ const anchors = new Map();
+ const ctx: LinkContext = {
+ anchors,
+ diagramStems: new Set(diagrams.map((d) => d.stem)),
+ images: new Map(diagrams.map((d) => [`${d.stem}.svg`, d.file])),
+ };
+
+ const css = readFileSync(join(import.meta.dirname, 'docs.css'));
+ const cssHash = createHash('sha256').update(css).digest('hex').slice(0, 10);
+ const cssPath = `${DOCS_BASE}/assets/docs-${cssHash}.css`;
+ files.set(cssPath.slice(1), css);
+ const ogImage = `${SITE_ORIGIN}${DOCS_BASE}/img/${OG_IMAGE_NAME}`;
+ files.set(`docs/img/${OG_IMAGE_NAME}`, readFileSync(join(REPO_ROOT, OG_IMAGE_SOURCE)));
+
+ const lexed = PAGES.map((page) => ({ page, lexed: page.generated ? undefined : lexPage(page, anchors) }));
+
+ const emit = (shell: Shell): void => {
+ files.set(`${shell.path.slice(1)}index.html`, renderShell(shell));
+ };
+
+ emit({
+ path: `${DOCS_BASE}/`,
+ canonical: canonicalFor(`${DOCS_BASE}/`),
+ title: 'Documentation',
+ heading: 'Deepmark documentation',
+ description:
+ 'Documentation for Deepmark, a dungeon crawler on Stellar where the contract replays every run: how it works, how to verify a run, contracts, threat model and roadmap.',
+ bodyHtml: indexBody(),
+ cssHref: cssPath,
+ ogImage,
+ next: PAGES[0],
+ });
+
+ lexed.forEach(({ page, lexed: lex }, i) => {
+ const bodyHtml = lex ? renderPage(lex, ctx) : diagramsBody(diagrams);
+ const path = pageUrl(page.slug);
+ emit({
+ path,
+ canonical: canonicalFor(path),
+ title: page.title,
+ heading: page.title,
+ description: page.description,
+ bodyHtml,
+ cssHref: cssPath,
+ ogImage,
+ currentSlug: page.slug,
+ prev: PAGES[i - 1],
+ next: PAGES[i + 1],
+ sourceFile: page.generated ? DIAGRAMS_DIR : page.parts[0]?.file,
+ });
+ });
+
+ for (const d of diagrams) files.set(`docs/img/${d.stem}.svg`, d.bytes);
+ for (const [name, file] of ctx.images) {
+ if (!files.has(`docs/img/${name}`)) files.set(`docs/img/${name}`, readFileSync(join(REPO_ROOT, file)));
+ }
+
+ for (const [path, content] of files) {
+ if (typeof content === 'string' && content.includes(EM_DASH)) {
+ throw new Error(`docs: ${path} contains an em dash, fix the source markdown`);
+ }
+ }
+ return files;
+}
diff --git a/client/docs-site/sources.ts b/client/docs-site/sources.ts
new file mode 100644
index 0000000..4fac090
--- /dev/null
+++ b/client/docs-site/sources.ts
@@ -0,0 +1,62 @@
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { SourcePart } from './manifest';
+
+/** The repository root, one level above the client. */
+export const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+export interface ExtractedPart {
+ markdown: string;
+ /** Headings below the dropped section heading move up one level. */
+ shiftDepth: boolean;
+}
+
+interface HeadingLine {
+ index: number;
+ depth: number;
+ text: string;
+}
+
+const FENCE = /^\s*(```|~~~)/;
+const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
+
+function headingLines(lines: string[]): HeadingLine[] {
+ const found: HeadingLine[] = [];
+ let inFence = false;
+ lines.forEach((line, index) => {
+ if (FENCE.test(line)) {
+ inFence = !inFence;
+ return;
+ }
+ if (inFence) return;
+ const match = HEADING.exec(line);
+ if (match?.[1] && match[2]) found.push({ index, depth: match[1].length, text: match[2] });
+ });
+ return found;
+}
+
+export function readRepoFile(file: string): string {
+ return readFileSync(join(REPO_ROOT, file), 'utf8');
+}
+
+/** Cut the requested slice out of a source file. Throws when a heading is missing. */
+export function extractPart(part: SourcePart): ExtractedPart {
+ const lines = readRepoFile(part.file).split('\n');
+ const headings = headingLines(lines);
+ const h1 = headings.find((h) => h.depth === 1);
+ const afterTitle = h1 ? h1.index + 1 : 0;
+
+ if (part.heading) {
+ const start = headings.find((h) => h.depth === 2 && h.text === part.heading);
+ if (!start) throw new Error(`docs: heading "${part.heading}" not found in ${part.file}`);
+ const next = headings.find((h) => h.index > start.index && h.depth <= 2);
+ const end = next ? next.index : lines.length;
+ const from = part.dropHeading ? start.index + 1 : start.index;
+ return { markdown: lines.slice(from, end).join('\n'), shiftDepth: Boolean(part.dropHeading) };
+ }
+
+ const firstH2 = headings.find((h) => h.depth === 2);
+ const end = part.intro && firstH2 ? firstH2.index : lines.length;
+ return { markdown: lines.slice(afterTitle, end).join('\n'), shiftDepth: false };
+}
diff --git a/client/docs-site/template.ts b/client/docs-site/template.ts
new file mode 100644
index 0000000..b69fee0
--- /dev/null
+++ b/client/docs-site/template.ts
@@ -0,0 +1,105 @@
+import { DOCS_BASE, GITHUB_BLOB, GITHUB_REPO, PAGES, SITE_ORIGIN, pageUrl, type DocPage } from './manifest';
+import { escapeHtml } from './html';
+
+export interface Shell {
+ /** Route path with a leading slash, for example /docs/overview/. */
+ path: string;
+ /** The URL search engines should index. */
+ canonical: string;
+ title: string;
+ description: string;
+ heading: string;
+ bodyHtml: string;
+ cssHref: string;
+ ogImage: string;
+ /** The nav entry to mark as current, or undefined for the index. */
+ currentSlug?: string;
+ prev?: DocPage;
+ next?: DocPage;
+ /** Repository file the page comes from, for the edit link. */
+ sourceFile?: string;
+}
+
+const SITE_NAME = 'Deepmark docs';
+
+function navHtml(currentSlug: string | undefined): string {
+ const items = PAGES.map((page) => {
+ const current = page.slug === currentSlug ? ' aria-current="page"' : '';
+ return `${escapeHtml(page.title)} `;
+ }).join('\n');
+ const home = currentSlug === undefined ? ' aria-current="page"' : '';
+ return ``;
+}
+
+function pagerHtml(prev: DocPage | undefined, next: DocPage | undefined, isHome: boolean): string {
+ const link = (page: DocPage, rel: 'prev' | 'next', label: string): string =>
+ ``;
+ const left = prev
+ ? link(prev, 'prev', 'Previous')
+ : isHome
+ ? ''
+ : ``;
+ const right = next ? link(next, 'next', 'Next') : '';
+ return ``;
+}
+
+export function renderShell(shell: Shell): string {
+ const fullTitle = shell.path === `${DOCS_BASE}/` ? `${SITE_NAME}: ${shell.title}` : `${shell.title} | ${SITE_NAME}`;
+ const description = escapeHtml(shell.description);
+ const edit = shell.sourceFile
+ ? `View the source on GitHub `
+ : `View the repository on GitHub `;
+ return `
+
+
+
+
+${escapeHtml(fullTitle)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Skip to content
+
+
+
+
Pages
+
+${navHtml(shell.currentSlug)}
+
+
+
+${escapeHtml(shell.heading)}
+${shell.bodyHtml}
+
+${pagerHtml(shell.prev, shell.next, shell.currentSlug === undefined)}
+
+
+
+
+
+`;
+}
+
+export function canonicalFor(path: string): string {
+ return path === `${DOCS_BASE}/` ? `${SITE_ORIGIN}${DOCS_BASE}` : `${SITE_ORIGIN}${path}`;
+}
diff --git a/client/index.html b/client/index.html
index 2f19f7b..1fe4c1e 100644
--- a/client/index.html
+++ b/client/index.html
@@ -8,7 +8,7 @@
-
+
Deepmark
diff --git a/client/package.json b/client/package.json
index 0b7de03..574d96a 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,13 +15,19 @@
"golden": "bun run scripts/golden-vectors.ts",
"kits": "bun run scripts/kit-difficulty.ts",
"test": "bun test",
+ "test:coverage": "bun test --coverage",
+ "lint": "biome lint .",
+ "format": "biome format --write .",
+ "format:check": "biome format .",
"perf": "bun run scripts/perf.ts",
"a11y": "bun run scripts/a11y.ts",
"contrast": "bun run scripts/contrast.ts"
},
"devDependencies": {
+ "@biomejs/biome": "2.5.15",
"@types/bun": "^1.4.2",
"axe-core": "^4.14.0",
+ "marked": "^18.1.0",
"puppeteer-core": "^25.12.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
diff --git a/client/scripts/a11y.ts b/client/scripts/a11y.ts
index cf3c03d..811b8ab 100644
--- a/client/scripts/a11y.ts
+++ b/client/scripts/a11y.ts
@@ -173,14 +173,24 @@ async function targetFindings(page: Page): Promise {
if (hidden || node.classList.contains('sr-only')) continue;
if (Math.round(rect.width) < min || Math.round(rect.height) < min) {
const label = node.getAttribute('aria-label') ?? node.textContent?.trim().slice(0, 30) ?? '';
- out.push(`${node.tagName.toLowerCase()}.${node.className.toString().split(' ')[0]} "${label}" ${Math.round(rect.width)}x${Math.round(rect.height)}`);
+ out.push(
+ `${node.tagName.toLowerCase()}.${node.className.toString().split(' ')[0]} "${label}" ${Math.round(rect.width)}x${Math.round(rect.height)}`
+ );
}
}
return out;
}, MIN_TARGET_PX);
return small.length === 0
? []
- : [{ kind: 'target', rule: 'target-size-44', impact: 'serious', detail: `Controls under ${MIN_TARGET_PX} by ${MIN_TARGET_PX} px`, nodes: small }];
+ : [
+ {
+ kind: 'target',
+ rule: 'target-size-44',
+ impact: 'serious',
+ detail: `Controls under ${MIN_TARGET_PX} by ${MIN_TARGET_PX} px`,
+ nodes: small,
+ },
+ ];
}
/** Animations still looping once the screen has settled. */
@@ -193,7 +203,15 @@ async function motionFindings(page: Page): Promise {
);
return looping.length === 0
? []
- : [{ kind: 'motion', rule: 'reduced-motion-loop', impact: 'serious', detail: 'Animation still looping with reduced motion on', nodes: looping }];
+ : [
+ {
+ kind: 'motion',
+ rule: 'reduced-motion-loop',
+ impact: 'serious',
+ detail: 'Animation still looping with reduced motion on',
+ nodes: looping,
+ },
+ ];
}
async function auditScreen(pass: Pass, scenario: Scenario, browser: Awaited>): Promise {
@@ -202,7 +220,11 @@ async function auditScreen(pass: Pass, scenario: Scenario, browser: Awaited BLOCKING_IMPACTS.includes(f.impact)).length };
}
@@ -215,7 +237,9 @@ const KEY_SETTLE_MS = 250;
const describeFocus = (page: Page): Promise =>
page.evaluate(() => {
const a = document.activeElement;
- return a ? `${a.tagName.toLowerCase()}.${a.className.toString().split(' ')[0]} "${(a.textContent ?? '').trim().slice(0, 24)}"` : 'nothing';
+ return a
+ ? `${a.tagName.toLowerCase()}.${a.className.toString().split(' ')[0]} "${(a.textContent ?? '').trim().slice(0, 24)}"`
+ : 'nothing';
});
/** Tabs until a control matching the selector has focus. Returns false when it never does. */
@@ -244,10 +268,16 @@ async function keyboardFindings(browser: Awaited undefined);
const opened = await page.evaluate(() => document.querySelector('.panel-title')?.textContent ?? '');
check(opened === 'Your vault', `Enter on the focused Vault item opened "${opened || 'nothing'}" instead of the vault`);
- check(await page.evaluate(() => document.activeElement?.classList.contains('panel-title') ?? false), `Focus did not move into the vault screen (on ${await describeFocus(page)})`);
+ check(
+ await page.evaluate(() => document.activeElement?.classList.contains('panel-title') ?? false),
+ `Focus did not move into the vault screen (on ${await describeFocus(page)})`
+ );
await page.keyboard.press('Escape');
await settle(SHORT_SETTLE_MS);
- check(await page.evaluate(() => document.activeElement?.matches('.menu-item.is-primary') ?? false), `Focus did not return to the menu after Escape (on ${await describeFocus(page)})`);
+ check(
+ await page.evaluate(() => document.activeElement?.matches('.menu-item.is-primary') ?? false),
+ `Focus did not return to the menu after Escape (on ${await describeFocus(page)})`
+ );
}
// Forge and descend without touching the pointer: Enter on the menu, Enter inside the name field.
@@ -274,7 +304,9 @@ async function keyboardFindings(browser: Awaited {
return puppeteer.launch({
diff --git a/client/scripts/budgets.ts b/client/scripts/budgets.ts
index 1682792..16dca96 100644
--- a/client/scripts/budgets.ts
+++ b/client/scripts/budgets.ts
@@ -61,15 +61,38 @@ export function checkBudgets(report: BudgetInput, profile: ProfileName): Violati
};
over('Cold load transfer', report.load.bytesKB, BUDGETS.transferKB, ' KB', 'Check for a new or unoptimized asset.');
- over('Cumulative layout shift', report.load.clsScore, BUDGETS.layoutShift, '', 'Reserve space for late content or load styles before first paint.');
+ over(
+ 'Cumulative layout shift',
+ report.load.clsScore,
+ BUDGETS.layoutShift,
+ '',
+ 'Reserve space for late content or load styles before first paint.'
+ );
if (report.load.longTasks.count > 0) {
- over('Longest task during load', report.load.longTasks.maxMs, longTaskMs, ' ms', 'Split or defer the work that blocks the main thread.');
+ over(
+ 'Longest task during load',
+ report.load.longTasks.maxMs,
+ longTaskMs,
+ ' ms',
+ 'Split or defer the work that blocks the main thread.'
+ );
}
const sampled = report.frames.find((f) => f.throttle === throttle);
if (!sampled) {
- out.push({ metric: 'Frame time', actual: 0, limit: BUDGETS.frameMs, message: `No frame sample at ${throttle}x CPU throttle was recorded.` });
+ out.push({
+ metric: 'Frame time',
+ actual: 0,
+ limit: BUDGETS.frameMs,
+ message: `No frame sample at ${throttle}x CPU throttle was recorded.`,
+ });
} else {
- over(`Frame time ${percentile} at ${throttle}x throttle`, sampled[percentile], BUDGETS.frameMs, ' ms', 'Profile the render loop for per frame allocations.');
+ over(
+ `Frame time ${percentile} at ${throttle}x throttle`,
+ sampled[percentile],
+ BUDGETS.frameMs,
+ ' ms',
+ 'Profile the render loop for per frame allocations.'
+ );
}
return out;
}
diff --git a/client/scripts/chain-smoke.ts b/client/scripts/chain-smoke.ts
index c19d0a2..d654552 100644
--- a/client/scripts/chain-smoke.ts
+++ b/client/scripts/chain-smoke.ts
@@ -39,7 +39,7 @@ lap(`account ready ${address.slice(0, 6)}...`);
/** Opens runs until the scripted player clears all three floors, then finishes that run. */
async function clearOnce(choice: HeroChoice): Promise<{ heroId: number | undefined; score: number }> {
for (let attempt = 1; attempt <= 12; attempt++) {
- let ticket;
+ let ticket: Awaited> | undefined;
for (let retry = 0; retry < 4; retry++) {
try {
ticket = await startRun(keypair, choice);
@@ -50,32 +50,38 @@ async function clearOnce(choice: HeroChoice): Promise<{ heroId: number | undefin
}
}
if (!ticket) throw new Error('could not open a run');
- const state = newRun(
- { base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank },
- ticket.seed,
- ticket.look
- );
+ const state = newRun({ base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank }, ticket.seed, ticket.look);
playBalanced(state);
const cleared = state.resultKind === 'extracted' && state.floorNumber === 3;
- lap(`attempt ${attempt} seed ${ticket.seed} rank ${ticket.rank}: ${cleared ? 'cleared' : state.resultKind} (${state.actions.length} actions)`);
+ lap(
+ `attempt ${attempt} seed ${ticket.seed} rank ${ticket.rank}: ${cleared ? 'cleared' : state.resultKind} (${state.actions.length} actions)`
+ );
await sleep(Math.ceil(state.actions.length / 16) * 5000 + 6000);
const { outcome, hash } = await finishRun(keypair, state.actions);
- lap(`finish_run ${hash} extracted=${outcome.extracted} floor=${outcome.floor_reached} score=${outcome.score} hero=${outcome.hero_id ?? 'none'}`);
+ lap(
+ `finish_run ${hash} extracted=${outcome.extracted} floor=${outcome.floor_reached} score=${outcome.score} hero=${outcome.hero_id ?? 'none'}`
+ );
if (cleared) return { heroId: outcome.hero_id, score: outcome.score };
}
throw new Error('no clear in 12 attempts');
}
-const first = await clearOnce(
- freshChoice({ look: { name: 'Smoke Test', emblem: 2, cloak: 3 }, base: 1, weapon: 1, charm: 2 })
-);
+const first = await clearOnce(freshChoice({ look: { name: 'Smoke Test', emblem: 2, cloak: 3 }, base: 1, weapon: 1, charm: 2 }));
if (first.heroId === undefined) throw new Error('the cleared run did not earn a delver');
const heroes = await fetchHeroes(address);
-console.log('vault after the first clear:', heroes.map((h) => ({ id: h.id, name: h.look.name, clears: h.clears })));
+console.log(
+ 'vault after the first clear:',
+ heroes.map((h) => ({ id: h.id, name: h.look.name, clears: h.clears }))
+);
const second = await clearOnce(veteranChoice(first.heroId));
const after = await fetchHeroes(address);
-console.log('vault after the veteran clear:', after.map((h) => ({ id: h.id, clears: h.clears })), 'second hero id', second.heroId);
+console.log(
+ 'vault after the veteran clear:',
+ after.map((h) => ({ id: h.id, clears: h.clears })),
+ 'second hero id',
+ second.heroId
+);
const record = await fetchRecord(address);
console.log({
diff --git a/client/scripts/contrast.ts b/client/scripts/contrast.ts
index 6607464..da0f9a2 100644
--- a/client/scripts/contrast.ts
+++ b/client/scripts/contrast.ts
@@ -139,10 +139,13 @@ if (MARKDOWN) {
console.log('| Use | Foreground | Surface | Ratio | Needs | Result |');
console.log('|---|---|---|---|---|---|');
for (const row of rows) {
- console.log(`| ${row.use} | \`${row.fg}\` | \`${row.bg}\` | ${row.ratio.toFixed(2)} | ${row.min} | ${row.ratio >= row.min ? 'pass' : 'fail'} |`);
+ console.log(
+ `| ${row.use} | \`${row.fg}\` | \`${row.bg}\` | ${row.ratio.toFixed(2)} | ${row.min} | ${row.ratio >= row.min ? 'pass' : 'fail'} |`
+ );
}
} else {
- for (const row of rows) console.log(`${row.ratio >= row.min ? 'ok ' : 'FAIL'} ${row.ratio.toFixed(2).padStart(5)} (needs ${row.min}) ${row.use}`);
+ for (const row of rows)
+ console.log(`${row.ratio >= row.min ? 'ok ' : 'FAIL'} ${row.ratio.toFixed(2).padStart(5)} (needs ${row.min}) ${row.use}`);
console.log(failing.length === 0 ? 'CONTRAST: all pairs pass' : `CONTRAST: ${failing.length} pairs below threshold`);
}
process.exit(failing.length === 0 ? 0 : 1);
diff --git a/client/scripts/golden-vectors.ts b/client/scripts/golden-vectors.ts
index 67bbccb..35f5d1d 100644
--- a/client/scripts/golden-vectors.ts
+++ b/client/scripts/golden-vectors.ts
@@ -98,10 +98,7 @@ function playOne(g: Rng): { line: string } | null {
} else {
const relic = state.relics.find((r) => !r.collected);
const goal: Point = habits.wantsRelic && relic ? relic : state.layout.stairsPos;
- const code =
- stepToward(state, goal, habits.avoidsFights) ??
- stepToward(state, goal, false) ??
- (randInt(g, 0, 3) as ActionCode);
+ const code = stepToward(state, goal, habits.avoidsFights) ?? stepToward(state, goal, false) ?? (randInt(g, 0, 3) as ActionCode);
applyAction(state, code);
}
kinds.push(...relicKinds(state));
diff --git a/client/scripts/kit-difficulty.ts b/client/scripts/kit-difficulty.ts
index d947f8b..5b24970 100644
--- a/client/scripts/kit-difficulty.ts
+++ b/client/scripts/kit-difficulty.ts
@@ -30,9 +30,7 @@ function clearPercent(base: number, weapon: number, charm: number, seeds: readon
function main(): void {
const stream = mulberry32(SEED_STREAM);
const seeds = Array.from({ length: RUNS_PER_KIT }, () => Math.floor(stream() * 0xffffffff));
- const table = HERO_ORDER.map((_, base) =>
- WEAPONS.map((_, weapon) => CHARMS.map((_, charm) => clearPercent(base, weapon, charm, seeds)))
- );
+ const table = HERO_ORDER.map((_, base) => WEAPONS.map((_, weapon) => CHARMS.map((_, charm) => clearPercent(base, weapon, charm, seeds))));
const rows = table.map((weapons) => ` [${weapons.map((charms) => `[${charms.join(', ')}]`).join(', ')}],`);
console.log(
[
diff --git a/client/scripts/perf.ts b/client/scripts/perf.ts
index 8523021..fe8d058 100644
--- a/client/scripts/perf.ts
+++ b/client/scripts/perf.ts
@@ -81,12 +81,15 @@ async function coldLoad(page: Page): Promise {
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') perf.fcp = e.startTime;
}).observe({ type: 'paint', buffered: true });
- (w.__shifts as string[] | undefined) ?? (w.__shifts = []);
+ if (!w.__shifts) w.__shifts = [];
new PerformanceObserver((l) => {
for (const e of l.getEntries() as unknown as { value: number; hadRecentInput: boolean; sources?: { node?: Element }[] }[]) {
if (e.hadRecentInput) continue;
perf.cls += e.value;
- const names = (e.sources ?? []).map((src) => `${src.node?.nodeName ?? '?'}.${(src.node as HTMLElement | undefined)?.className ?? ''}#${(src.node as HTMLElement | undefined)?.id ?? ''}`);
+ const names = (e.sources ?? []).map(
+ (src) =>
+ `${src.node?.nodeName ?? '?'}.${(src.node as HTMLElement | undefined)?.className ?? ''}#${(src.node as HTMLElement | undefined)?.id ?? ''}`
+ );
(w.__shifts as string[]).push(`${e.value.toFixed(3)} ${names.join(' | ')}`);
}
}).observe({ type: 'layout-shift', buffered: true });
@@ -223,7 +226,13 @@ try {
violations = checkBudgets({ load, frames }, PROFILE);
if (JSON_OUTPUT) {
- console.log(JSON.stringify({ url: URL_UNDER_TEST, profile: PROFILE, budgets: { ...BUDGETS, ...PROFILES[PROFILE] }, load, frames, violations }, null, 2));
+ console.log(
+ JSON.stringify(
+ { url: URL_UNDER_TEST, profile: PROFILE, budgets: { ...BUDGETS, ...PROFILES[PROFILE] }, load, frames, violations },
+ null,
+ 2
+ )
+ );
}
if (violations.length === 0) log('PERF BUDGETS: all within budget');
else for (const v of violations) console.error(`PERF BUDGET EXCEEDED: ${v.message}`);
diff --git a/client/src/app/actions.ts b/client/src/app/actions.ts
new file mode 100644
index 0000000..606c462
--- /dev/null
+++ b/client/src/app/actions.ts
@@ -0,0 +1,160 @@
+import { ActionCode, applyAction, drainEvents, type RunState } from '../game/state';
+import { saveRun } from '../game/savedRun';
+import { resultGuardActive } from '../input/guard';
+import { onAction, type Action } from '../input/keyboard';
+import { face } from '../render/canvas';
+import { isMuted, sfxStep, sfxUi, toggleMuted } from '../render/audio';
+import { noteHintInput, resetHints } from '../render/hints';
+import {
+ copyRecoveryCode,
+ loadBoard,
+ openLeaderboard,
+ openVault,
+ checkRestoreCode,
+ confirmRestore,
+ cancelRestore,
+ startRestore,
+ verifyOnChain,
+} from './onchain';
+import { endExhaustedRun, leaveResult, openCreator } from './run';
+import { copyChallengeLink, shareResult } from './share';
+import { handleEvent } from './events';
+import { session } from './session';
+
+const DIRS: Partial> = {
+ up: [0, -1],
+ down: [0, 1],
+ left: [-1, 0],
+ right: [1, 0],
+};
+
+const MOVE_CODES = {
+ up: ActionCode.Up,
+ down: ActionCode.Down,
+ left: ActionCode.Left,
+ right: ActionCode.Right,
+} as const;
+
+export function syncMuteButton(): void {
+ const button = document.getElementById('mute');
+ if (!button) return;
+ const muted = isMuted();
+ button.textContent = muted ? 'Sound off' : 'Sound on';
+ button.setAttribute('aria-pressed', muted ? 'true' : 'false');
+}
+
+function handleStatsAction(action: Action): void {
+ if (action === 'cancel' && session.restoreStep !== 'closed') {
+ // Escape backs out of the restore step first, the card stays open.
+ cancelRestore();
+ return;
+ }
+ if (action === 'stats' || action === 'cancel') {
+ session.showStats = false;
+ session.recoveryNote = '';
+ cancelRestore();
+ }
+ if (action === 'copyRecovery') void copyRecoveryCode();
+ if (action === 'restoreRecovery') startRestore();
+ if (action === 'restoreCheck') void checkRestoreCode();
+ if (action === 'restoreConfirm') void confirmRestore();
+ if (action === 'restoreCancel') cancelRestore();
+ if (action === 'replayHints') resetHints();
+}
+
+function handleLeaderboardAction(action: Action): void {
+ if (action === 'leaderboard' || action === 'cancel') session.showLeaderboard = false;
+ if (action === 'retryBoard') void loadBoard();
+ if (action === 'tabFresh' || action === 'tabVeteran') {
+ session.boardTab = action === 'tabVeteran' ? 'veteran' : 'fresh';
+ void loadBoard();
+ }
+}
+
+function handleVaultAction(action: Action): void {
+ if (action === 'vault' || action === 'cancel') session.showVault = false;
+ if (action === 'retryVault') void openVault();
+}
+
+function handleTitleAction(action: Action): void {
+ if (action === 'confirm') openCreator();
+ if (action === 'vault') void openVault();
+ if (action === 'stats') session.showStats = true;
+ if (action === 'leaderboard') void openLeaderboard();
+}
+
+function handleResultAction(action: Action, resultRun: RunState): void {
+ if (action === 'confirm' && !resultGuardActive(session.resultShownAt, performance.now())) leaveResult();
+ if (action === 'again') {
+ leaveResult();
+ openCreator();
+ }
+ if (action === 'retryVerify' && session.failedRun) void verifyOnChain(session.failedRun);
+ if (action === 'shareSave' || action === 'shareCopy') void shareResult(resultRun, action === 'shareCopy' ? 'copy' : 'save');
+ if (action === 'shareChallenge') void copyChallengeLink(resultRun);
+ if (action === 'stats') session.showStats = true;
+ if (action === 'vault') {
+ leaveResult();
+ void openVault();
+ }
+}
+
+/** Applies one input to a live run, then settles the consequences (save, exhaustion, events). */
+function handlePlayAction(run: RunState, action: Action): void {
+ const actionsBefore = run.actions.length;
+ if (run.mode === 'combat') {
+ if (action === 'confirm') applyAction(run, ActionCode.Attack);
+ if (action === 'cancel') applyAction(run, ActionCode.Retreat);
+ noteHintInput(action);
+ } else if (run.mode === 'choice') {
+ if (action === 'confirm') applyAction(run, ActionCode.Descend);
+ if (action === 'cancel') applyAction(run, ActionCode.Extract);
+ noteHintInput(action);
+ } else {
+ const dir = DIRS[action];
+ if (dir) {
+ if (dir[0] !== 0) face(dir[0] < 0 ? -1 : 1);
+ const moved = applyAction(run, MOVE_CODES[action as keyof typeof MOVE_CODES]);
+ if (moved && run.mode === 'explore') sfxStep();
+ noteHintInput(action);
+ }
+ }
+
+ if (run.exhausted) endExhaustedRun(run);
+ if (!session.practice && run.actions.length !== actionsBefore) saveRun(run);
+ for (const event of drainEvents(run)) handleEvent(event);
+}
+
+/** Routes an input to the screen that owns it, topmost screen first. */
+function dispatch(action: Action): void {
+ if (action === 'mute') {
+ toggleMuted();
+ syncMuteButton();
+ return;
+ }
+ if (!session.run) sfxUi();
+ if (session.showStats) {
+ handleStatsAction(action);
+ } else if (session.showLeaderboard) {
+ handleLeaderboardAction(action);
+ } else if (session.starting) {
+ // The descent scene owns the screen until the run opens.
+ } else if (session.bossIntro) {
+ session.bossIntro.skip();
+ } else if (session.showCreator) {
+ if (action === 'cancel') session.showCreator = false;
+ } else if (session.showVault) {
+ handleVaultAction(action);
+ } else if (!session.run) {
+ handleTitleAction(action);
+ } else if (session.run.mode === 'result') {
+ handleResultAction(action, session.run);
+ } else {
+ handlePlayAction(session.run, action);
+ }
+}
+
+export function installInput(): void {
+ syncMuteButton();
+ onAction(dispatch);
+}
diff --git a/client/src/app/canvas.ts b/client/src/app/canvas.ts
new file mode 100644
index 0000000..ab5324d
--- /dev/null
+++ b/client/src/app/canvas.ts
@@ -0,0 +1,6 @@
+const element = document.getElementById('game') as HTMLCanvasElement;
+const context = element.getContext('2d');
+if (!context) throw new Error('Canvas 2D context unavailable');
+
+export const canvas: HTMLCanvasElement = element;
+export const ctx: CanvasRenderingContext2D = context;
diff --git a/client/src/app/chainQueue.ts b/client/src/app/chainQueue.ts
new file mode 100644
index 0000000..2392e7d
--- /dev/null
+++ b/client/src/app/chainQueue.ts
@@ -0,0 +1,37 @@
+/**
+ * One chain transaction at a time. Two transactions from the same account
+ * built before the first lands would reuse a sequence number and one of them
+ * would fail, so starting a run waits for the previous run's verification.
+ */
+export class ChainQueue {
+ private tail: Promise = Promise.resolve();
+
+ /** Runs `job` after every earlier job has settled, whether it succeeded or not. */
+ enqueue(job: () => Promise): Promise {
+ const next = this.tail.then(job, job);
+ this.tail = next.catch(() => undefined);
+ return next;
+ }
+}
+
+export const chainQueue = new ChainQueue();
+
+export function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export function withTimeout(promise: Promise, ms: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('Timed out waiting for Stellar')), ms);
+ promise.then(
+ (value) => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (error: unknown) => {
+ clearTimeout(timer);
+ reject(error);
+ }
+ );
+ });
+}
diff --git a/client/src/app/descendHeartbeat.ts b/client/src/app/descendHeartbeat.ts
new file mode 100644
index 0000000..641d5c8
--- /dev/null
+++ b/client/src/app/descendHeartbeat.ts
@@ -0,0 +1,30 @@
+import { sfxHeartbeat } from '../render/audio';
+import { DESCEND_BEAT_DECAY, DESCEND_BEAT_MIN_MS, DESCEND_BEAT_START_MS } from '../constants';
+
+/** Hovering Descend on the bank-or-push prompt: the heartbeat quickens the longer it holds. */
+let hovering = false;
+let timer: ReturnType | null = null;
+
+function scheduleBeat(delay: number): void {
+ timer = setTimeout(() => {
+ if (!hovering) return;
+ sfxHeartbeat();
+ scheduleBeat(Math.max(DESCEND_BEAT_MIN_MS, delay * DESCEND_BEAT_DECAY));
+ }, delay);
+}
+
+export function isDescendHovering(): boolean {
+ return hovering;
+}
+
+export function onDescendHover(next: boolean): void {
+ if (next === hovering) return;
+ hovering = next;
+ if (next) {
+ sfxHeartbeat();
+ scheduleBeat(DESCEND_BEAT_START_MS);
+ } else if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+}
diff --git a/client/src/app/events.ts b/client/src/app/events.ts
new file mode 100644
index 0000000..a86ce37
--- /dev/null
+++ b/client/src/app/events.ts
@@ -0,0 +1,191 @@
+import type { GameEvent } from '../game/state';
+import { GRID_WIDTH, MAX_FLOOR, TILE_SIZE } from '../game/floor';
+import { HEROES, HERO_ORDER } from '../game/heroes';
+import {
+ sfxBanner,
+ sfxBossIntro,
+ sfxDead,
+ sfxDescend,
+ sfxEnemyDied,
+ sfxExtracted,
+ sfxFloorCleared,
+ sfxHitEnemy,
+ sfxHitPlayer,
+ sfxRelic,
+ stopAmbience,
+ startAmbience,
+} from '../render/audio';
+import { showBanner } from '../render/banner';
+import { playBossIntro, preloadBossArt } from '../render/bossIntro';
+import { hitStop, punch } from '../render/clock';
+import { flashActor, spawnBurst, spawnPopup, spawnRelicTravel, triggerShake } from '../render/effects';
+import { showHint } from '../render/hints';
+import { playAttack, resetMotion } from '../render/motion';
+import { preloadShareAssets } from '../render/shareCard';
+import { canvas } from './canvas';
+import { FLOOR_NAMES } from './floorNames';
+import { verifyOnChain } from './onchain';
+import { settleLocalProgress } from './run';
+import { session } from './session';
+
+/** Popup colours by kind of number. */
+const POPUP_DAMAGE_DEALT = '#e5e7eb';
+const POPUP_DAMAGE_TAKEN = '#ef5350';
+const POPUP_RELIC = '#ffca28';
+
+/** Space reserved below the board's bottom edge so a travelling relic never leaves the canvas. */
+const RELIC_TRAVEL_EDGE_MARGIN = 4;
+const BOSS_NAME = 'Warden Prime';
+
+/** A hit of this size or more freezes the frame a little longer. */
+const HEAVY_HIT = 5;
+
+type EventOf = Extract;
+
+function onEnemyHit(event: EventOf<'enemyHit'>): void {
+ const run = session.run;
+ if (run) {
+ playAttack('player', run.player.x, run.player.y);
+ const struck = run.enemies.findIndex((e) => e.x === event.x && e.y === event.y);
+ if (struck >= 0) flashActor(`enemy-${struck}`);
+ }
+ spawnPopup(event.x, event.y, `-${event.amount}`, POPUP_DAMAGE_DEALT);
+ spawnBurst(event.x, event.y, 'hit');
+ sfxHitEnemy();
+ hitStop(event.amount >= HEAVY_HIT ? 90 : 55);
+ punch(Math.min(1, 0.25 + event.amount * 0.1));
+ triggerShake(100 + event.amount * 12, 3 + event.amount);
+}
+
+function onPlayerHit(event: EventOf<'playerHit'>): void {
+ const run = session.run;
+ if (run && run.activeEnemyIndex !== null) {
+ const enemy = run.enemies[run.activeEnemyIndex];
+ if (enemy) playAttack(`enemy-${run.activeEnemyIndex}`, enemy.x, enemy.y);
+ }
+ spawnPopup(event.x, event.y, `-${event.amount}`, POPUP_DAMAGE_TAKEN);
+ flashActor('player');
+ spawnBurst(event.x, event.y, 'hit');
+ sfxHitPlayer();
+ hitStop(80);
+ punch(0.5 + Math.min(0.5, event.amount * 0.1));
+ triggerShake(150 + event.amount * 15, 5 + event.amount * 1.5);
+}
+
+function onEnemyDied(event: EventOf<'enemyDied'>): void {
+ spawnBurst(event.x, event.y, 'kill');
+ hitStop(120);
+ punch(0.8);
+ sfxEnemyDied();
+ triggerShake(120, 5);
+}
+
+function onRelic(event: EventOf<'relic'>): void {
+ spawnPopup(event.x, event.y, '+relic', POPUP_RELIC);
+ spawnBurst(event.x, event.y, 'relic');
+ sfxRelic(event.kind);
+ const offsetX = (canvas.width - GRID_WIDTH * TILE_SIZE) / 2;
+ const startX = offsetX + event.x * TILE_SIZE + TILE_SIZE / 2;
+ const startY = event.y * TILE_SIZE + TILE_SIZE / 2;
+ const counter = document.getElementById('hud-relics');
+ const canvasRect = canvas.getBoundingClientRect();
+ if (!counter || canvasRect.width <= 0 || canvasRect.height <= 0) return;
+ // The counter sits below the board in this layout: the point aims at the counter's
+ // live horizontal position but can only ever reach the board's own bottom edge.
+ const counterRect = counter.getBoundingClientRect();
+ const scaleX = canvas.width / canvasRect.width;
+ const scaleY = canvas.height / canvasRect.height;
+ const endX = Math.min(canvas.width, Math.max(0, (counterRect.left + counterRect.width / 2 - canvasRect.left) * scaleX));
+ const endY = Math.min(canvas.height - RELIC_TRAVEL_EDGE_MARGIN, (counterRect.top - canvasRect.top) * scaleY);
+ spawnRelicTravel(startX, startY, endX, endY, event.kind);
+}
+
+function onFloorCleared(): void {
+ sfxFloorCleared();
+ showHint('choice');
+ if (session.run && session.run.floorNumber === MAX_FLOOR - 1) preloadBossArt();
+}
+
+function onDescended(event: EventOf<'descended'>): void {
+ resetMotion();
+ sfxDescend();
+ startAmbience(event.floor);
+ if (event.floor < MAX_FLOOR) {
+ showBanner(`Floor ${event.floor}`, FLOOR_NAMES[event.floor] ?? '', 'floor');
+ sfxBanner();
+ return;
+ }
+ const stage = document.querySelector('.stage');
+ if (!stage) {
+ sfxBossIntro();
+ return;
+ }
+ session.bossIntro = playBossIntro(
+ stage,
+ BOSS_NAME,
+ () => sfxBossIntro(),
+ () => {
+ session.bossIntro = null;
+ }
+ );
+}
+
+/** Books the finished run locally and, unless it is practice, sends its log to the contract. */
+function settleFinishedRun(): void {
+ const run = session.run;
+ if (run) settleLocalProgress(run, session.practice);
+ session.runId += 1;
+ if (run && !session.practice) void verifyOnChain(run);
+}
+
+function onExtracted(): void {
+ stopAmbience();
+ sfxExtracted();
+ const run = session.run;
+ if (run) {
+ const heroId = HERO_ORDER[run.loadout.base] ?? 'lantern';
+ preloadShareAssets(HEROES[heroId].veteranArt, run.loadout);
+ }
+ settleFinishedRun();
+}
+
+function onDead(): void {
+ stopAmbience();
+ sfxDead();
+ triggerShake(250, 10);
+ canvas.classList.add('board-sunk');
+ settleFinishedRun();
+}
+
+/** Turns one simulation event into its feedback: effects, sound, hints and, at the end of a run, settlement. */
+export function handleEvent(event: GameEvent): void {
+ switch (event.type) {
+ case 'enemyHit':
+ onEnemyHit(event);
+ break;
+ case 'playerHit':
+ onPlayerHit(event);
+ break;
+ case 'enemyDied':
+ onEnemyDied(event);
+ break;
+ case 'relic':
+ onRelic(event);
+ break;
+ case 'floorCleared':
+ onFloorCleared();
+ break;
+ case 'descended':
+ onDescended(event);
+ break;
+ case 'extracted':
+ onExtracted();
+ break;
+ case 'dead':
+ onDead();
+ break;
+ case 'combatStart':
+ showHint('combat');
+ break;
+ }
+}
diff --git a/client/src/app/failures.ts b/client/src/app/failures.ts
new file mode 100644
index 0000000..db87358
--- /dev/null
+++ b/client/src/app/failures.ts
@@ -0,0 +1,69 @@
+import { CONTRACT_ERROR, isContractError } from '../stellar/choice';
+
+/**
+ * How a failed `finish_run` should be treated.
+ * - wait: network trouble or TooFast, heals by itself, retried automatically.
+ * - mint: the relics could not be delivered. The contract reverted and kept the run open, so it is safe to retry after the relic setup is redone.
+ * - notReady: the contract is not set up yet. The run is kept, the player retries by hand.
+ * - closed: the run is not open on chain (settled, expired or never started).
+ * - permanent: the contract refused this log for good.
+ */
+export type FailureKind = 'wait' | 'mint' | 'notReady' | 'closed' | 'permanent';
+
+/** Raised when a retry found no open run and the record could not prove the run was settled. */
+export class SettlementUnconfirmed extends Error {
+ constructor() {
+ super('The run may already be recorded, but Stellar did not confirm it');
+ this.name = 'SettlementUnconfirmed';
+ }
+}
+
+function isContractRefusal(err: unknown): boolean {
+ return err instanceof Error && err.message.includes('Error(Contract, #');
+}
+
+/** The one place that maps a contract refusal to how the client reacts. */
+export function failureKind(err: unknown): FailureKind {
+ if (!isContractRefusal(err)) return 'wait';
+ if (isContractError(err, CONTRACT_ERROR.TooFast)) return 'wait';
+ if (isContractError(err, CONTRACT_ERROR.MintFailed)) return 'mint';
+ if (isContractError(err, CONTRACT_ERROR.NotInitialized)) return 'notReady';
+ if (isContractError(err, CONTRACT_ERROR.RunNotStarted)) return 'closed';
+ return 'permanent';
+}
+
+/** The contract's own refusals that waiting cannot fix and that leave no run to resume. */
+export function isPermanentFailure(err: unknown): boolean {
+ const kind = failureKind(err);
+ return kind === 'permanent' || kind === 'closed';
+}
+
+/** Failures after which the open run and its action log must be kept so the player can try again. */
+export function keepsRun(err: unknown): boolean {
+ return !isPermanentFailure(err);
+}
+
+/** A player-facing sentence for why a run could not be settled. */
+export function describeFailure(err: unknown): string {
+ if (err instanceof SettlementUnconfirmed) {
+ return 'This run may already be recorded on Stellar, but we could not confirm it yet. Check again in a moment.';
+ }
+ if (isContractError(err, CONTRACT_ERROR.MintFailed)) {
+ return 'Your relics could not be delivered yet, usually because your relic setup is not finished. Your run is safe. Try again.';
+ }
+ if (isContractError(err, CONTRACT_ERROR.NotInitialized)) {
+ return 'The game is not ready on Stellar yet. Your run is kept. Try again in a moment.';
+ }
+ if (isContractError(err, CONTRACT_ERROR.RunNotStarted)) {
+ return 'This run is no longer open on Stellar. It was already settled or it expired.';
+ }
+ if (isContractError(err, CONTRACT_ERROR.NotHeroOwner)) return 'This delver changed hands during the run, so it cannot be settled.';
+ if (
+ isContractError(err, CONTRACT_ERROR.RunIncomplete) ||
+ isContractError(err, CONTRACT_ERROR.TrailingActions) ||
+ isContractError(err, CONTRACT_ERROR.InvalidAction)
+ ) {
+ return 'The contract could not reproduce this run, so it was not recorded.';
+ }
+ return 'Stellar could not be reached to settle this run.';
+}
diff --git a/client/src/app/floorNames.ts b/client/src/app/floorNames.ts
new file mode 100644
index 0000000..4a9029d
--- /dev/null
+++ b/client/src/app/floorNames.ts
@@ -0,0 +1,2 @@
+/** Banner names for each floor, keyed by floor number. */
+export const FLOOR_NAMES: Record = { 1: 'The Reliquary', 2: 'The Drowned Chapel', 3: 'The Obsidian Crypt' };
diff --git a/client/src/app/onchain.ts b/client/src/app/onchain.ts
new file mode 100644
index 0000000..f7a4fe9
--- /dev/null
+++ b/client/src/app/onchain.ts
@@ -0,0 +1,312 @@
+import { delverOutcome } from '../game/delverOutcome';
+import { clearSavedRun } from '../game/savedRun';
+import type { RunState } from '../game/state';
+import type { LeaderboardRow, OnchainSnapshot } from '../render/chromeTypes';
+import { sfxHeroEarned } from '../render/audio';
+import { chain } from '../stellar/lazy';
+import { shortenAddress } from '../render/dom';
+import type { FinishedRun, TopEntry } from '../stellar/contract';
+import { fetchRelicBalances } from '../stellar/relics';
+import { HERO_EARNED_DELAY_MS, RESTORE_FIELD_ID, JUST_VERIFIED_MS, LEADERBOARD_ROWS, VERIFY_ATTEMPTS, VERIFY_RETRY_MS } from '../constants';
+import { chainQueue, sleep } from './chainQueue';
+import { describeFailure, failureKind, isPermanentFailure, keepsRun, SettlementUnconfirmed } from './failures';
+import { checkSettlement } from './settlement';
+import { session } from './session';
+
+/** Reads the player's record and relic balances. The title never waits for it. */
+export async function refreshOnchain(): Promise {
+ try {
+ const api = await chain();
+ const keypair = await api.getPlayerKeypair();
+ session.playerAddress = keypair.publicKey();
+ const [record, balances] = await Promise.all([api.fetchRecord(session.playerAddress), fetchRelicBalances(session.playerAddress)]);
+ session.onchainRecord = record;
+ session.relicBalances = balances;
+ session.chainRead = 'ready';
+ } catch (err) {
+ session.chainRead = 'unavailable';
+ console.warn('Deepmark: could not read the on-chain record yet', err);
+ }
+}
+
+export function onchainSnapshot(): OnchainSnapshot | null {
+ const record = session.onchainRecord;
+ if (!record) return null;
+ return {
+ bestFloor: record.best_floor,
+ bestScore: record.best_score,
+ runs: record.total_runs,
+ extractions: record.extractions,
+ deaths: record.deaths,
+ forfeits: record.forfeits,
+ relicsBanked: record.relics_banked,
+ };
+}
+
+function toRows(entries: TopEntry[]): LeaderboardRow[] {
+ return entries.slice(0, LEADERBOARD_ROWS).map((entry) => ({
+ address: entry.player,
+ name: entry.name,
+ emblem: entry.emblem,
+ score: entry.score,
+ floor: entry.floor,
+ hpLeft: entry.hp_left,
+ actions: entry.actions,
+ }));
+}
+
+export async function loadBoard(): Promise {
+ const tab = session.boardTab;
+ session.leaderboardLoading = true;
+ session.leaderboardRows = null;
+ session.leaderboardFailed = false;
+ try {
+ const api = await chain();
+ const entries = tab === 'veteran' ? await api.fetchTopVeteran() : await api.fetchTopFresh();
+ if (tab === session.boardTab) session.leaderboardRows = toRows(entries);
+ } catch (err) {
+ console.warn('Deepmark: could not load the leaderboard', err);
+ if (tab === session.boardTab) session.leaderboardFailed = true;
+ } finally {
+ if (tab === session.boardTab) session.leaderboardLoading = false;
+ }
+}
+
+export async function openLeaderboard(): Promise {
+ session.showLeaderboard = true;
+ if (session.leaderboardRows || session.leaderboardLoading) return;
+ await loadBoard();
+}
+
+export async function copyRecoveryCode(): Promise {
+ try {
+ const api = await chain();
+ const code = api.getRecoveryCode();
+ if (!code) throw new Error('No account yet');
+ await navigator.clipboard.writeText(code);
+ session.recoveryNote = 'Recovery code copied. Keep it somewhere private, anyone who has it can use your account.';
+ } catch (err) {
+ console.warn('Deepmark: could not copy the recovery code', err);
+ session.recoveryNote = 'The recovery code could not be copied from this browser.';
+ }
+}
+
+/** The validated code waiting for the player's confirmation. Module private so it is never rendered or logged. */
+let pendingRestoreCode: string | null = null;
+
+export function startRestore(): void {
+ pendingRestoreCode = null;
+ session.restoreError = '';
+ session.restoreTarget = '';
+ session.restoreStep = 'enter';
+}
+
+export function cancelRestore(): void {
+ pendingRestoreCode = null;
+ session.restoreError = '';
+ session.restoreTarget = '';
+ session.restoreStep = 'closed';
+}
+
+/** Validates the pasted code strictly. Nothing is replaced until the player confirms the next step. */
+export async function checkRestoreCode(): Promise {
+ const field = document.getElementById(RESTORE_FIELD_ID);
+ const pasted = field instanceof HTMLInputElement ? field.value : '';
+ if (field instanceof HTMLInputElement) field.value = '';
+ try {
+ const api = await chain();
+ const code = pasted.trim();
+ if (!api.isRecoveryCode(code)) {
+ session.restoreError = 'That recovery code is not valid. Nothing was changed.';
+ return;
+ }
+ pendingRestoreCode = code;
+ session.restoreTarget = shortenAddress(api.recoveryCodeAddress(code));
+ session.restoreError = '';
+ session.restoreStep = 'confirm';
+ } catch (err) {
+ console.warn('Deepmark: could not check the recovery code', err);
+ session.restoreError = 'That recovery code could not be checked. Nothing was changed.';
+ }
+}
+
+/** Swaps this browser's account for the one in the confirmed recovery code, then reloads. */
+export async function confirmRestore(): Promise {
+ const code = pendingRestoreCode;
+ if (!code) return;
+ try {
+ const api = await chain();
+ api.restoreRecoveryCode(code);
+ pendingRestoreCode = null;
+ window.location.reload();
+ } catch (err) {
+ console.warn('Deepmark: could not restore the account', err);
+ session.restoreError = 'That recovery code could not be used. Your current account was kept.';
+ session.restoreStep = 'enter';
+ pendingRestoreCode = null;
+ }
+}
+
+export async function openVault(): Promise {
+ session.showVault = true;
+ if (session.vaultHeroes || session.vaultLoading) return;
+ session.vaultLoading = true;
+ try {
+ const api = await chain();
+ const keypair = await api.getPlayerKeypair();
+ session.vaultHeroes = await api.fetchHeroes(keypair.publicKey());
+ } catch (err) {
+ console.warn('Deepmark: could not read the vault', err);
+ session.vaultHeroes = null;
+ } finally {
+ session.vaultLoading = false;
+ }
+}
+
+type Api = Awaited>;
+
+/** Either the run just settled, or proof from the record that an earlier send already settled it. */
+type Settlement = { kind: 'finished'; run: FinishedRun } | { kind: 'recorded' };
+
+async function readRunCount(api: Api): Promise {
+ try {
+ return (await api.fetchRecord((await api.getPlayerKeypair()).publicKey()))?.total_runs ?? 0;
+ } catch {
+ return null;
+ }
+}
+
+/** Redoes the account's relic setup. A failure is logged only: the next send reports the real cause. */
+async function redoRelicSetup(api: Api): Promise {
+ session.chainStatus = 'Getting your relic setup ready...';
+ session.verifyState = 'waiting';
+ try {
+ await api.prepareRelicSetup();
+ } catch (err) {
+ console.warn('Deepmark: relic setup did not finish', err);
+ }
+}
+
+/** A retry found no open run. If an earlier send of this very run happened, the record says whether it landed. */
+async function settleWhenClosed(api: Api, err: unknown, previouslySent: boolean): Promise {
+ if (!previouslySent) throw err;
+ const address = (await api.getPlayerKeypair()).publicKey();
+ const verdict = await checkSettlement(() => api.fetchRecord(address), session.finishBaselineRuns);
+ if (verdict === 'recorded') return { kind: 'recorded' };
+ if (verdict === 'unknown') throw new SettlementUnconfirmed();
+ throw err;
+}
+
+async function submitWithRetries(api: Api, finished: RunState): Promise {
+ const previouslySent = session.finishAttemptRunId === session.runId;
+ if (!previouslySent) {
+ session.finishAttemptRunId = session.runId;
+ session.finishBaselineRuns = await readRunCount(api);
+ }
+ let setupRedone = false;
+ let sentBefore = previouslySent;
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ const run = await api.finishRun(await api.getPlayerKeypair(), finished.actions);
+ return { kind: 'finished', run };
+ } catch (err) {
+ const kind = failureKind(err);
+ if (kind === 'closed') return settleWhenClosed(api, err, sentBefore);
+ sentBefore = true;
+ if (kind === 'mint' && !setupRedone) {
+ // The contract reverted and kept the run open, so one more send after the setup is safe.
+ setupRedone = true;
+ await redoRelicSetup(api);
+ continue;
+ }
+ if (kind !== 'wait' || attempt >= VERIFY_ATTEMPTS - 1) throw err;
+ session.chainStatus = 'Waiting for Stellar to settle this run...';
+ session.verifyState = 'waiting';
+ await sleep(VERIFY_RETRY_MS);
+ }
+ }
+}
+
+/** Sends the log, waiting out the contract's minimum play time and brief network trouble. */
+function submitLog(finished: RunState): Promise {
+ // The whole retry loop holds the chain queue, sleeps included, so start_run
+ // cannot slip in between attempts and overwrite the run that is still open.
+ return chainQueue.enqueue(async () => {
+ const api = await chain();
+ return submitWithRetries(api, finished);
+ });
+}
+
+/** Adds the player's leaderboard rank and refreshed record. Decoration only, so it never throws. */
+async function decorateResult(): Promise {
+ try {
+ await refreshOnchain();
+ const api = await chain();
+ if (session.runWasVeteran && session.chainHeroId !== null) {
+ session.chainClears = (await api.fetchHero(session.chainHeroId))?.clears ?? null;
+ }
+ const top = session.runWasVeteran ? await api.fetchTopVeteran() : await api.fetchTopFresh();
+ const position = top.findIndex((entry) => entry.player === session.playerAddress);
+ session.chainRank = position >= 0 ? position + 1 : null;
+ session.leaderboardRows = null;
+ } catch (err) {
+ console.warn('Deepmark: could not refresh the leaderboard rank', err);
+ }
+}
+
+/** Sends the action log. The contract replays it and decides the result. */
+export async function verifyOnChain(finished: RunState): Promise {
+ session.chainStatus = 'Verifying on Stellar...';
+ session.verifyState = 'pending';
+ session.canRetry = false;
+ let settled: Settlement;
+ try {
+ settled = await submitLog(finished);
+ } catch (err) {
+ console.warn('Deepmark: failed to verify this run on-chain', err);
+ session.chainStatus = describeFailure(err);
+ session.verifyState = 'failed';
+ session.canRetry = !isPermanentFailure(err);
+ session.failedRun = finished;
+ // Only a refusal that waiting cannot fix means the run is gone for good. Otherwise the saved run and its log stay for another try.
+ if (!keepsRun(err)) clearSavedRun();
+ return;
+ }
+
+ if (settled.kind === 'recorded') {
+ // The send landed but its response was lost. The record proves it, so show the verified state.
+ session.failedRun = null;
+ clearSavedRun();
+ session.chainTx = '';
+ session.chainHeroId = null;
+ session.vaultHeroes = null;
+ session.chainStatus = 'Verified on Stellar (the confirmation arrived late, so the details come from your record)';
+ session.verifyState = 'verified';
+ session.justVerifiedUntil = performance.now() + JUST_VERIFIED_MS;
+ await decorateResult();
+ return;
+ }
+
+ const { hash, outcome } = settled.run;
+ session.failedRun = null;
+ clearSavedRun();
+ session.chainTx = hash;
+ session.chainHeroId = outcome.hero_id ?? null;
+ if (session.chainHeroId !== null) session.vaultHeroes = null;
+ const relicsMatch = outcome.relics_common + outcome.relics_rare + outcome.relics_core === finished.relicsThisRun;
+ const agrees =
+ outcome.floor_reached === finished.floorNumber &&
+ outcome.score === finished.scoreThisRun &&
+ outcome.hp_left === finished.player.hp &&
+ relicsMatch;
+ if (!agrees) console.error('Deepmark: the contract result differs from the local simulation', outcome);
+ session.chainStatus = agrees ? 'Verified on Stellar' : 'Verified on Stellar (this browser disagreed, the chain result stands)';
+ session.verifyState = 'verified';
+ session.justVerifiedUntil = performance.now() + JUST_VERIFIED_MS;
+ const earned = delverOutcome(session.chainHeroId, session.runWasVeteran, null).kind === 'earned';
+ if (earned) window.setTimeout(sfxHeroEarned, HERO_EARNED_DELAY_MS);
+
+ // The run is settled. Anything below only decorates the result screen, so a
+ // failure here must never turn a confirmed transaction into an error.
+ await decorateResult();
+}
diff --git a/client/src/app/run.ts b/client/src/app/run.ts
new file mode 100644
index 0000000..2d0f767
--- /dev/null
+++ b/client/src/app/run.ts
@@ -0,0 +1,274 @@
+import { loadDraft, loadoutOf, type Draft } from '../game/draft';
+import { HEROES, HERO_ORDER } from '../game/heroes';
+import { CHARMS, WEAPONS } from '../game/items';
+import { rankForClears, type Loadout, type Look } from '../game/loadout';
+import { recordRun } from '../game/progress';
+import { clearSavedRun, loadSavedRun, planResume, saveRun, type ResumePlan } from '../game/savedRun';
+import { newRun, type RunState } from '../game/state';
+import { showBanner } from '../render/banner';
+import { cloakFilter } from '../render/emblems';
+import { resetRelicTravel } from '../render/effects';
+import { showHint } from '../render/hints';
+import { resetMotion } from '../render/motion';
+import { startAmbience, sfxDescend } from '../render/audio';
+import { preloadShareAssets } from '../render/shareCard';
+import { CONTRACT_ERROR, freshChoice, isContractError, veteranChoice } from '../stellar/choice';
+import type { Hero, HeroChoice, RunTicket } from '../stellar/contract';
+import { chain } from '../stellar/lazy';
+import { isRetryableTxFailure } from '../stellar/txOutcome';
+import { retryTransient } from '../stellar/transient';
+import {
+ MIN_SCENE_MS,
+ OFFLINE_EXTRA_HOLD_MS,
+ READY_HOLD_MS,
+ START_CONTENTION_BACKOFF_MS,
+ START_CONTENTION_JITTER_MS,
+ START_CONTENTION_RETRIES,
+ START_RETRIES,
+ START_RETRY_MS,
+ START_TIMEOUT_MS,
+} from '../constants';
+import { canvas } from './canvas';
+import { raceSkip, SKIPPED, SkipGate } from './skipGate';
+import { chainQueue, sleep, withTimeout } from './chainQueue';
+import { FLOOR_NAMES } from './floorNames';
+import { session } from './session';
+import { verifyOnChain } from './onchain';
+
+export interface DelverPlan {
+ choice: HeroChoice;
+ loadout: Loadout;
+ look: Look;
+ veteran: boolean;
+}
+
+export function planFresh(chosen: Draft): DelverPlan {
+ return {
+ choice: freshChoice({ look: chosen.look, base: chosen.base, weapon: chosen.weapon, charm: chosen.charm }),
+ loadout: loadoutOf(chosen),
+ look: chosen.look,
+ veteran: false,
+ };
+}
+
+export function planVeteran(hero: Hero): DelverPlan {
+ return {
+ choice: veteranChoice(hero.id),
+ loadout: { base: hero.base, weapon: hero.weapon, charm: hero.charm, rank: rankForClears(hero.clears) },
+ look: hero.look,
+ veteran: true,
+ };
+}
+
+function resetChainReadout(): void {
+ session.chainStatus = '';
+ session.chainTx = '';
+ session.chainRank = null;
+ session.chainHeroId = null;
+ session.chainClears = null;
+}
+
+/** Calls start_run, riding out the contract's cooldown between delves. */
+async function openRunOnChain(choice: HeroChoice, gate: SkipGate): Promise {
+ const api = await chain();
+ const keypair = await api.getPlayerKeypair();
+ if (!gate.closed) session.startStage = 'opening';
+ let cooldownRetries = 0;
+ let contentionRetries = 0;
+ for (;;) {
+ try {
+ // A passing HTTP or network error on the send is retried before the descent gives up.
+ return await retryTransient(() => api.startRun(keypair, choice), { cancelled: () => gate.closed });
+ } catch (err) {
+ if (gate.closed) throw err;
+ if (isContractError(err, CONTRACT_ERROR.StartCooldown) && cooldownRetries < START_RETRIES) {
+ cooldownRetries += 1;
+ await sleep(START_RETRY_MS);
+ } else if (isRetryableTxFailure(err) && contentionRetries < START_CONTENTION_RETRIES) {
+ // Several players starting in the same ledger can clash on the shared
+ // player index. The failed transaction had no effect, so simulate again.
+ contentionRetries += 1;
+ await sleep(START_CONTENTION_BACKOFF_MS * contentionRetries + Math.random() * START_CONTENTION_JITTER_MS);
+ } else {
+ throw err;
+ }
+ }
+ }
+}
+
+function enterFirstFloor(): void {
+ resetMotion();
+ startAmbience(1);
+ showBanner('Floor 1', FLOOR_NAMES[1] ?? '');
+}
+
+function descentOptionsFor(plan: DelverPlan, onSkip: () => void): NonNullable {
+ const heroId = HERO_ORDER[plan.loadout.base] ?? 'lantern';
+ return {
+ art: plan.loadout.rank > 0 ? HEROES[heroId].veteranArt : HEROES[heroId].art,
+ filter: cloakFilter(plan.look.cloak, plan.loadout.base),
+ name: plan.look.name,
+ emblem: plan.look.emblem,
+ onSkip,
+ kit: [
+ HEROES[heroId].name,
+ WEAPONS[plan.loadout.weapon]?.name ?? '',
+ CHARMS[plan.loadout.charm]?.name ?? '',
+ ...(plan.loadout.rank > 0 ? [`Veteran rank ${plan.loadout.rank}`] : []),
+ ].join(' · '),
+ };
+}
+
+/** Plays a labelled practice run in place of a verified one. `immediate` skips the notice hold. */
+async function startPractice(plan: DelverPlan, immediate: boolean, sceneRemaining: () => number): Promise {
+ if (!immediate) await sleep(sceneRemaining());
+ session.startStage = 'offline';
+ if (!immediate) await sleep(READY_HOLD_MS + OFFLINE_EXTRA_HOLD_MS);
+ enterFirstFloor();
+ session.run = newRun(plan.loadout, session.challengeSeed ?? undefined, plan.look);
+ session.challengeSeed = null;
+ session.practice = true;
+}
+
+/** Opens a run on chain so the contract issues the seed. Falls back to a practice run. */
+export async function beginRun(plan: DelverPlan): Promise {
+ if (session.starting) return;
+ session.starting = true;
+ session.displayedRelics = 0;
+ resetRelicTravel();
+ canvas.classList.remove('board-sunk');
+ session.startStage = 'account';
+ const openedAt = performance.now();
+ const gate = new SkipGate();
+ session.descent = descentOptionsFor(plan, () => gate.skip());
+ resetChainReadout();
+ session.runWasVeteran = plan.veteran;
+ const sceneRemaining = (): number => Math.max(0, MIN_SCENE_MS - (performance.now() - openedAt));
+ try {
+ const ticket = await raceSkip(
+ withTimeout(
+ chainQueue.enqueue(() => openRunOnChain(plan.choice, gate)),
+ START_TIMEOUT_MS
+ ),
+ gate
+ );
+ if (ticket === SKIPPED) {
+ await startPractice(plan, true, sceneRemaining);
+ } else {
+ await sleep(sceneRemaining());
+ session.startStage = 'ready';
+ sfxDescend();
+ await sleep(READY_HOLD_MS);
+ enterFirstFloor();
+ // The ticket is authoritative: the contract fixed the delver and the seed.
+ const loadout: Loadout = { base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank };
+ session.run = newRun(loadout, ticket.seed, ticket.look);
+ session.practice = false;
+ saveRun(session.run);
+ }
+ } catch (err) {
+ console.warn('Deepmark: could not open a verified run, starting a practice run', err);
+ await startPractice(plan, false, sceneRemaining);
+ } finally {
+ gate.close();
+ session.starting = false;
+ session.descent = null;
+ }
+ showHint('move');
+}
+
+/** A run that grows past what the contract accepts can no longer be verified. */
+export function endExhaustedRun(state: RunState): void {
+ if (state.mode === 'result') return;
+ state.mode = 'result';
+ state.resultKind = 'dead';
+ state.relicsThisRun = 0;
+ state.scoreThisRun = 0;
+ session.practice = true;
+ clearSavedRun();
+ session.chainStatus = 'This run grew too long to be verified.';
+}
+
+/** Books a finished run into this browser's own progress, unless it was a practice run. The simulation stays free of storage so a log can be replayed safely. */
+export function settleLocalProgress(finished: RunState, practice: boolean): void {
+ const extracted = finished.resultKind === 'extracted';
+ const result = recordRun({
+ floorReached: finished.floorNumber,
+ relicsKept: finished.relicsThisRun,
+ score: finished.scoreThisRun,
+ extracted,
+ heroId: finished.heroId,
+ practice,
+ });
+ session.progress = result.progress;
+ finished.newBest = result.isNewBest && extracted;
+}
+
+/** Puts a run rebuilt from its saved log back on screen, at the point it was left. */
+function presentResumedRun(plan: Exclude, ticket: RunTicket): void {
+ const resumed = plan.run;
+ resetMotion();
+ resetRelicTravel();
+ canvas.classList.remove('board-sunk');
+ resetChainReadout();
+ session.canRetry = false;
+ session.practice = false;
+ session.runWasVeteran = typeof ticket.veteran === 'number';
+ session.displayedRelics = resumed.relicsThisRun;
+ session.run = resumed;
+ if (plan.kind === 'continue') {
+ startAmbience(resumed.floorNumber);
+ showBanner('Run restored', FLOOR_NAMES[resumed.floorNumber] ?? '');
+ return;
+ }
+ // The run had already ended when the page went away: show its result and send the log.
+ session.runId += 1;
+ if (resumed.resultKind === 'dead') canvas.classList.add('board-sunk');
+ else preloadShareAssets(HEROES[resumed.heroId].veteranArt, resumed.loadout);
+ void verifyOnChain(resumed);
+}
+
+/**
+ * If this browser holds a saved run, asks the contract whether that very run is
+ * still open and, if so, restores it. A saved run the contract no longer knows
+ * is dropped. If Stellar cannot be read the save is kept for the next visit.
+ */
+export async function resumeOpenRun(): Promise {
+ if (session.resumeChecked || session.chainRead !== 'ready') return;
+ session.resumeChecked = true;
+ const saved = loadSavedRun();
+ if (!saved) return;
+ try {
+ const api = await chain();
+ const ticket = await api.fetchOpenRun(session.playerAddress);
+ if (session.run || session.starting) return;
+ const plan = planResume(saved, ticket);
+ if (plan.kind === 'discard' || !ticket) {
+ clearSavedRun();
+ return;
+ }
+ presentResumedRun(plan, ticket);
+ } catch (err) {
+ console.warn('Deepmark: could not check for an open run', err);
+ }
+}
+
+/** Leaves the result screen for the title, clearing everything that belonged to the finished run. */
+export function leaveResult(): void {
+ resetMotion();
+ session.run = null;
+ session.chainStatus = '';
+ session.chainTx = '';
+ session.chainRank = null;
+ session.canRetry = false;
+}
+
+/** Opens the delver creator, keeping a delver that arrived with a challenge link. */
+export function openCreator(): void {
+ if (session.pendingChallengeDraft) {
+ session.pendingChallengeDraft = false;
+ } else {
+ session.draft = loadDraft();
+ }
+ session.showCreator = true;
+}
diff --git a/client/src/app/session.ts b/client/src/app/session.ts
new file mode 100644
index 0000000..9dd57a1
--- /dev/null
+++ b/client/src/app/session.ts
@@ -0,0 +1,135 @@
+import { loadDraft, takeChallengeFromUrl, type Draft } from '../game/draft';
+import { loadProgress, type Progress } from '../game/progress';
+import type { RunState } from '../game/state';
+import type { BoardTab, ChainRead, LeaderboardRow } from '../render/chromeTypes';
+import type { BossIntroHandle } from '../render/bossIntro';
+import type { DescentOptions, DescentStage } from '../render/descent';
+import type { VerifyState } from '../render/result';
+import type { Hero, PlayerRecord } from '../stellar/contract';
+import type { RelicBalances } from '../stellar/relics';
+
+export type RestoreStep = 'closed' | 'enter' | 'confirm';
+
+/** Everything the app layer shares between frames. One object so each module reads and writes the same state. */
+export interface Session {
+ run: RunState | null;
+ progress: Progress;
+ /** A finished run whose log could not be sent, kept so the player can retry. */
+ failedRun: RunState | null;
+ practice: boolean;
+ starting: boolean;
+ startStage: DescentStage;
+ descent: DescentOptions | null;
+ runId: number;
+ runWasVeteran: boolean;
+ /** What the HUD counter actually shows: bumps when a relic's travelling point lands, not when it is picked up. */
+ displayedRelics: number;
+ bossIntro: BossIntroHandle | null;
+ lastHeartbeat: number;
+ resultShownRunId: number;
+ resultShownAt: number;
+
+ draft: Draft;
+ challengeSeed: number | null;
+ pendingChallengeDraft: boolean;
+ showCreator: boolean;
+
+ showVault: boolean;
+ vaultHeroes: Hero[] | null;
+ vaultLoading: boolean;
+
+ showLeaderboard: boolean;
+ boardTab: BoardTab;
+ leaderboardLoading: boolean;
+ leaderboardRows: LeaderboardRow[] | null;
+ leaderboardFailed: boolean;
+
+ showStats: boolean;
+ recoveryNote: string;
+ /** Where the restore-from-a-code flow is. The pasted code itself is never kept here. */
+ restoreStep: RestoreStep;
+ restoreError: string;
+ /** The shortened public address of the account a pending restore would switch to. */
+ restoreTarget: string;
+
+ chainRead: ChainRead;
+ chainStatus: string;
+ chainTx: string;
+ chainRank: number | null;
+ chainHeroId: number | null;
+ chainClears: number | null;
+ canRetry: boolean;
+ verifyState: VerifyState;
+ /** Set only when a run verifies this session, so the leaderboard's own-row glow never shows on a cold load. */
+ justVerifiedUntil: number;
+ onchainRecord: PlayerRecord | null;
+ relicBalances: RelicBalances | null;
+ playerAddress: string;
+ resumeChecked: boolean;
+ /** The run whose log was last sent, so a later "no open run" answer can be checked against the record. */
+ finishAttemptRunId: number;
+ /** The player's run count read just before the first send, or null if it could not be read. */
+ finishBaselineRuns: number | null;
+}
+
+function createSession(): Session {
+ let draft = loadDraft();
+ let challengeSeed: number | null = null;
+ let pendingChallengeDraft = false;
+ const challenge = takeChallengeFromUrl();
+ if (challenge) {
+ draft = { ...draft, base: challenge.base, weapon: challenge.weapon, charm: challenge.charm };
+ challengeSeed = challenge.seed;
+ pendingChallengeDraft = true;
+ }
+ return {
+ run: null,
+ progress: loadProgress(),
+ failedRun: null,
+ practice: false,
+ starting: false,
+ startStage: 'account',
+ descent: null,
+ runId: 0,
+ runWasVeteran: false,
+ displayedRelics: 0,
+ bossIntro: null,
+ lastHeartbeat: 0,
+ resultShownRunId: -1,
+ resultShownAt: 0,
+ draft,
+ challengeSeed,
+ pendingChallengeDraft,
+ showCreator: false,
+ showVault: false,
+ vaultHeroes: null,
+ vaultLoading: false,
+ showLeaderboard: false,
+ boardTab: 'fresh',
+ leaderboardLoading: false,
+ leaderboardRows: null,
+ leaderboardFailed: false,
+ showStats: false,
+ recoveryNote: '',
+ restoreStep: 'closed',
+ restoreError: '',
+ restoreTarget: '',
+ chainRead: 'loading',
+ chainStatus: '',
+ chainTx: '',
+ chainRank: null,
+ chainHeroId: null,
+ chainClears: null,
+ canRetry: false,
+ verifyState: 'pending',
+ justVerifiedUntil: 0,
+ onchainRecord: null,
+ relicBalances: null,
+ playerAddress: '',
+ resumeChecked: false,
+ finishAttemptRunId: -1,
+ finishBaselineRuns: null,
+ };
+}
+
+export const session: Session = createSession();
diff --git a/client/src/app/settlement.ts b/client/src/app/settlement.ts
new file mode 100644
index 0000000..a9e0b07
--- /dev/null
+++ b/client/src/app/settlement.ts
@@ -0,0 +1,22 @@
+import type { PlayerRecord } from '../stellar/contract';
+
+/** What the contract's record says about a run whose finish response was lost. */
+export type SettlementCheck = 'recorded' | 'notRecorded' | 'unknown';
+
+/**
+ * `finish_run` bumps `total_runs` exactly once when it lands. A retry that finds
+ * no open run is only proof of settlement if the count moved past what it was
+ * before the first attempt. Without a baseline or a readable record we cannot say.
+ */
+export async function checkSettlement(
+ readRecord: () => Promise,
+ baselineRuns: number | null
+): Promise {
+ if (baselineRuns === null) return 'unknown';
+ try {
+ const record = await readRecord();
+ return (record?.total_runs ?? 0) > baselineRuns ? 'recorded' : 'notRecorded';
+ } catch {
+ return 'unknown';
+ }
+}
diff --git a/client/src/app/share.ts b/client/src/app/share.ts
new file mode 100644
index 0000000..0110359
--- /dev/null
+++ b/client/src/app/share.ts
@@ -0,0 +1,63 @@
+import type { RunState } from '../game/state';
+import { MAX_FLOOR } from '../game/floor';
+import { HEROES, HERO_ORDER } from '../game/heroes';
+import { cloakFilter } from '../render/emblems';
+import { challengeUrl, renderShareCard } from '../render/shareCard';
+import { OBJECT_URL_REVOKE_MS, SHARE_FLASH_MS } from '../constants';
+import { session } from './session';
+
+type ShareButton = 'shareSave' | 'shareCopy' | 'shareChallenge';
+
+function flashShareButton(action: ShareButton, label: string): void {
+ const btn = document.querySelector(`[data-action="${action}"]`);
+ if (!btn) return;
+ const original = btn.textContent ?? '';
+ btn.textContent = label;
+ window.setTimeout(() => {
+ btn.textContent = original;
+ }, SHARE_FLASH_MS);
+}
+
+export async function shareResult(target: RunState, mode: 'save' | 'copy'): Promise {
+ const heroId = HERO_ORDER[target.loadout.base] ?? 'lantern';
+ const blob = await renderShareCard({
+ name: target.look.name,
+ portraitArt: HEROES[heroId].veteranArt,
+ portraitFilter: cloakFilter(target.look.cloak, target.loadout.base),
+ weapon: target.loadout.weapon,
+ charm: target.loadout.charm,
+ floorReached: target.floorNumber,
+ maxFloor: MAX_FLOOR,
+ score: target.scoreThisRun,
+ relics: target.relicsThisRun,
+ rank: session.chainRank,
+ txHash: session.chainTx,
+ });
+ if (!blob) return;
+ if (mode === 'copy' && typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
+ try {
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+ flashShareButton('shareCopy', 'Copied');
+ return;
+ } catch {
+ // Clipboard image writes are not supported everywhere (notably iOS Safari): fall back to a download.
+ }
+ }
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `deepmark-${target.look.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.png`;
+ link.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_MS);
+ flashShareButton(mode === 'copy' ? 'shareCopy' : 'shareSave', 'Saved');
+}
+
+export async function copyChallengeLink(target: RunState): Promise {
+ const url = challengeUrl(target.seed, target.loadout);
+ try {
+ await navigator.clipboard.writeText(url);
+ flashShareButton('shareChallenge', 'Copied');
+ } catch {
+ // No clipboard permission: the button simply does not confirm.
+ }
+}
diff --git a/client/src/app/skipGate.ts b/client/src/app/skipGate.ts
new file mode 100644
index 0000000..184b32a
--- /dev/null
+++ b/client/src/app/skipGate.ts
@@ -0,0 +1,46 @@
+/** Resolves to this when the player chose to skip the wait. */
+export const SKIPPED: unique symbol = Symbol('skipped');
+
+/**
+ * One descent's "play a practice run now" switch. The chain job keeps running
+ * in the queue, but once the gate is closed its late results are ignored and
+ * it stops retrying, so they can never reach the practice run.
+ */
+export class SkipGate {
+ private requested = false;
+ private finished = false;
+ private resolveSkip: (value: typeof SKIPPED) => void = () => undefined;
+ readonly promise: Promise;
+
+ constructor() {
+ this.promise = new Promise((resolve) => {
+ this.resolveSkip = resolve;
+ });
+ }
+
+ /** The player asked for a practice run now. */
+ skip(): void {
+ if (this.finished) return;
+ this.requested = true;
+ this.resolveSkip(SKIPPED);
+ }
+
+ /** The descent no longer depends on the chain job (opened, timed out or skipped). */
+ close(): void {
+ this.finished = true;
+ }
+
+ get skipped(): boolean {
+ return this.requested;
+ }
+
+ /** True once the chain job's answer will be ignored, so it should stop touching shared state. */
+ get closed(): boolean {
+ return this.finished || this.requested;
+ }
+}
+
+/** The job's result, or SKIPPED if the player skipped first. A later failure of the job is swallowed. */
+export function raceSkip(job: Promise, gate: SkipGate): Promise {
+ return Promise.race([job, gate.promise]);
+}
diff --git a/client/src/constants.ts b/client/src/constants.ts
new file mode 100644
index 0000000..36ec011
--- /dev/null
+++ b/client/src/constants.ts
@@ -0,0 +1,57 @@
+/** Named timings and thresholds shared by the app layer and the chrome. Each value is in the unit its name says. */
+
+/** The bar and the heartbeat both treat this share of max health as low. */
+export const LOW_HEALTH_RATIO = 0.34;
+export const HEARTBEAT_MS = 1100;
+
+/** Waiting longer than this for the chain to open a run falls back to a practice run. */
+export const START_TIMEOUT_MS = 50_000;
+export const START_RETRIES = 3;
+export const START_RETRY_MS = 6_000;
+/** Starts that failed on chain because other players started in the same ledger are simulated again. */
+export const START_CONTENTION_RETRIES = 4;
+export const START_CONTENTION_BACKOFF_MS = 1_200;
+/** Random spread added to the backoff so clashing players do not retry in lockstep. */
+export const START_CONTENTION_JITTER_MS = 1_500;
+/** A transient network failure while sending a start (or account setup) is retried this many times, with a growing, jittered wait. */
+export const TRANSIENT_RETRIES = 3;
+export const TRANSIENT_BACKOFF_MS = 900;
+export const TRANSIENT_JITTER_MS = 700;
+/** The descent scene says Stellar is slow after this long, and offers a practice run right away after the next. */
+export const SLOW_NOTE_AFTER_MS = 6_000;
+export const SKIP_OFFER_AFTER_MS = 9_000;
+/** The stairs-open beat before the first floor appears, so the scene resolves instead of cutting. */
+export const READY_HOLD_MS = 1400;
+/** The practice fallback holds its notice a little longer so it can be read. */
+export const OFFLINE_EXTRA_HOLD_MS = 600;
+/** The scene always plays at least this long, so a fast network never shows a flash of it. */
+export const MIN_SCENE_MS = 3200;
+
+/** How many times a finished run's log is sent, waiting out the contract's minimum play time. */
+export const VERIFY_ATTEMPTS = 6;
+export const VERIFY_RETRY_MS = 6_000;
+
+/** The leaderboard's own-row glow lasts this long after a run verifies. */
+export const JUST_VERIFIED_MS = 4000;
+/** The earned delver sting lands just after the seal strike. */
+export const HERO_EARNED_DELAY_MS = 900;
+
+export const LEADERBOARD_ROWS = 10;
+
+/** Share buttons show their confirmation label this long. */
+export const SHARE_FLASH_MS = 1600;
+export const OBJECT_URL_REVOKE_MS = 2000;
+
+/** Hovering Descend: the heartbeat starts slow and quickens by this factor down to a floor. */
+export const DESCEND_BEAT_START_MS = 650;
+export const DESCEND_BEAT_MIN_MS = 220;
+export const DESCEND_BEAT_DECAY = 0.82;
+
+/** The relic counter bumps this long after a core relic lands, in step with the chime. */
+export const CORE_BUMP_DELAY_MS = 140;
+
+/** The board backdrop painted when no world is drawn. */
+export const BACKDROP_COLOR = '#07080e';
+
+/** The id of the in-page field the player pastes a recovery code into. */
+export const RESTORE_FIELD_ID = 'restore-code';
diff --git a/client/src/game-app.ts b/client/src/game-app.ts
index e52086a..c20194f 100644
--- a/client/src/game-app.ts
+++ b/client/src/game-app.ts
@@ -1,899 +1,134 @@
-import { ActionCode, applyAction, drainEvents, newRun, type GameEvent, type RunState } from './game/state';
-import { loadProgress, recordRun, type Progress } from './game/progress';
-import { clearSavedRun, loadSavedRun, planResume, saveRun, type ResumePlan } from './game/savedRun';
-import { onAction, type Action } from './input/keyboard';
-import { drawWorld, face } from './render/canvas';
-import { playAttack, resetMotion } from './render/motion';
-import {
- syncChrome,
- type BoardTab,
- type ChainRead,
- type LeaderboardRow,
- type OnchainSnapshot,
-} from './render/chrome';
-import { drainArrivedRelics, flashActor, resetRelicTravel, spawnBurst, spawnPopup, spawnRelicTravel, triggerShake } from './render/effects';
-import { showBanner } from './render/banner';
-import { playBossIntro, preloadBossArt, type BossIntroHandle } from './render/bossIntro';
-import { hitStop, punch } from './render/clock';
-import type { VerifyState } from './render/result';
-import {
- sfxDead,
- sfxDescend,
- sfxEnemyDied,
- sfxExtracted,
- sfxFloorCleared,
- sfxHitEnemy,
- sfxHitPlayer,
- sfxBanner,
- sfxBossIntro,
- sfxHeartbeat,
- sfxHeroEarned,
- sfxSeal,
- sfxRelic,
- sfxRelicArrive,
- sfxStep,
- sfxUi,
- isMuted,
- startAmbience,
- stopAmbience,
- toggleMuted,
-} from './render/audio';
-import { loadDraft, loadoutOf, takeChallengeFromUrl, type Draft } from './game/draft';
-import { noteHintInput, resetHints, showHint } from './render/hints';
-import { challengeUrl, preloadShareAssets, renderShareCard } from './render/shareCard';
-import { GRID_WIDTH, MAX_FLOOR, TILE_SIZE } from './game/floor';
-import { HEROES, HERO_ORDER } from './game/heroes';
-import { CHARMS, WEAPONS } from './game/items';
-import { cloakFilter } from './render/emblems';
-import type { DescentOptions, DescentStage } from './render/descent';
-import { rankForClears, type Loadout, type Look } from './game/loadout';
-import { chain, warmChain } from './stellar/lazy';
-import { CONTRACT_ERROR, freshChoice, isContractError, veteranChoice } from './stellar/choice';
-import type { FinishedRun, Hero, HeroChoice, PlayerRecord, RunTicket, TopEntry } from './stellar/contract';
-import { fetchRelicBalances, type RelicBalances } from './stellar/relics';
-
-const canvas = document.getElementById('game') as HTMLCanvasElement;
-const context = canvas.getContext('2d');
-if (!context) throw new Error('Canvas 2D context unavailable');
-const ctx: CanvasRenderingContext2D = context;
-
-let run: RunState | null = null;
-let progress: Progress = loadProgress();
-let chainStatus = '';
-let chainTx = '';
-let chainRank: number | null = null;
-let onchainRecord: PlayerRecord | null = null;
-let relicBalances: RelicBalances | null = null;
-let playerAddress = '';
-
-let draft: Draft = loadDraft();
-let challengeSeed: number | null = null;
-let pendingChallengeDraft = false;
-{
- const challenge = takeChallengeFromUrl();
- if (challenge) {
- draft = { ...draft, base: challenge.base, weapon: challenge.weapon, charm: challenge.charm };
- challengeSeed = challenge.seed;
- pendingChallengeDraft = true;
- }
-}
-let showCreator = false;
-let showVault = false;
-let vaultHeroes: Hero[] | null = null;
-let vaultLoading = false;
-let boardTab: BoardTab = 'fresh';
-let chainHeroId: number | null = null;
-let canRetry = false;
-let verifyState: VerifyState = 'pending';
-let runId = 0;
-/** What the HUD counter actually shows: bumps when a relic's travelling point lands, not when it is picked up. */
-let displayedRelics = 0;
-/** Set only when a run verifies this session, so the leaderboard's own-row glow never shows on a cold load. */
-let justVerifiedUntil = 0;
-
-const FLOOR_NAMES: Record = { 1: 'The Reliquary', 2: 'The Drowned Chapel', 3: 'The Obsidian Crypt' };
-const HEARTBEAT_MS = 1100;
-const LOW_HEALTH = 0.34;
-let lastHeartbeat = 0;
-let failedRun: RunState | null = null;
-let runWasVeteran = false;
-let showLeaderboard = false;
-let showStats = false;
-let starting = false;
-let startStage: DescentStage = 'account';
-let descent: DescentOptions | null = null;
-let practice = false;
-let leaderboardLoading = false;
-let leaderboardRows: LeaderboardRow[] | null = null;
-let chainRead: ChainRead = 'loading';
-let bossIntro: BossIntroHandle | null = null;
-
-/** Waiting longer than this for the chain to open a run falls back to a practice run. */
-const START_TIMEOUT_MS = 50_000;
-const START_RETRIES = 3;
-/** The stairs-open beat before the first floor appears, so the scene resolves instead of cutting. */
-const READY_HOLD_MS = 1400;
-/** The scene always plays at least this long, so a fast network never shows a flash of it. */
-const MIN_SCENE_MS = 3200;
-const START_RETRY_MS = 6_000;
-const LEADERBOARD_ROWS = 10;
-
-// One chain transaction at a time. Two transactions from the same account
-// built before the first lands would reuse a sequence number and one of them
-// would fail, so starting a run waits for the previous run's verification.
-let chainQueue: Promise = Promise.resolve();
-function enqueue(job: () => Promise): Promise {
- const next = chainQueue.then(job, job);
- chainQueue = next.catch(() => undefined);
- return next;
-}
-
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-function withTimeout(promise: Promise, ms: number): Promise {
- return new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error('Timed out waiting for Stellar')), ms);
- promise.then(
- (value) => {
- clearTimeout(timer);
- resolve(value);
- },
- (error: unknown) => {
- clearTimeout(timer);
- reject(error);
- }
- );
- });
-}
-
-async function refreshOnchain(): Promise {
- try {
- const api = await chain();
- const keypair = await api.getPlayerKeypair();
- playerAddress = keypair.publicKey();
- const [record, balances] = await Promise.all([api.fetchRecord(playerAddress), fetchRelicBalances(playerAddress)]);
- onchainRecord = record;
- relicBalances = balances;
- chainRead = 'ready';
- } catch (err) {
- chainRead = 'unavailable';
- console.warn('Deepmark: could not read the on-chain record yet', err);
- }
-}
+import { drainArrivedRelics } from './render/effects';
+import { syncChrome } from './render/chrome';
+import { drawWorld } from './render/canvas';
+import { sfxHeartbeat, sfxRelicArrive, sfxSeal } from './render/audio';
+import { warmChain } from './stellar/lazy';
+import { BACKDROP_COLOR, CORE_BUMP_DELAY_MS, HEARTBEAT_MS, LOW_HEALTH_RATIO } from './constants';
+import { installInput } from './app/actions';
+import { ctx } from './app/canvas';
+import { isDescendHovering, onDescendHover } from './app/descendHeartbeat';
+import { onchainSnapshot, refreshOnchain } from './app/onchain';
+import { beginRun, planFresh, planVeteran, resumeOpenRun } from './app/run';
+import { session } from './app/session';
+
+/** Composition root: wires input, the chain warm-up and the frame loop. The rules live in `app/`. */
// Load the Stellar chunk when idle, then read the player's record. The title never waits for either.
warmChain(() => void refreshOnchain().then(resumeOpenRun));
+installInput();
-function onchainSnapshot(): OnchainSnapshot | null {
- if (!onchainRecord) return null;
- return {
- bestFloor: onchainRecord.best_floor,
- bestScore: onchainRecord.best_score,
- runs: onchainRecord.total_runs,
- extractions: onchainRecord.extractions,
- deaths: onchainRecord.deaths,
- forfeits: onchainRecord.forfeits,
- relicsBanked: onchainRecord.relics_banked,
- };
-}
-
-function toRows(entries: TopEntry[]): LeaderboardRow[] {
- return entries.slice(0, LEADERBOARD_ROWS).map((entry) => ({
- address: entry.player,
- name: entry.name,
- emblem: entry.emblem,
- score: entry.score,
- floor: entry.floor,
- hpLeft: entry.hp_left,
- actions: entry.actions,
- }));
-}
-
-async function loadBoard(): Promise {
- const tab = boardTab;
- leaderboardLoading = true;
- leaderboardRows = null;
- try {
- const api = await chain();
- const entries = tab === 'veteran' ? await api.fetchTopVeteran() : await api.fetchTopFresh();
- if (tab === boardTab) leaderboardRows = toRows(entries);
- } catch (err) {
- console.warn('Deepmark: could not load the leaderboard', err);
- if (tab === boardTab) leaderboardRows = [];
- } finally {
- if (tab === boardTab) leaderboardLoading = false;
- }
-}
-
-async function openLeaderboard(): Promise {
- showLeaderboard = true;
- if (leaderboardRows || leaderboardLoading) return;
- await loadBoard();
-}
-
-async function openVault(): Promise {
- showVault = true;
- if (vaultHeroes || vaultLoading) return;
- vaultLoading = true;
- try {
- const api = await chain();
- const keypair = await api.getPlayerKeypair();
- vaultHeroes = await api.fetchHeroes(keypair.publicKey());
- } catch (err) {
- console.warn('Deepmark: could not read the vault', err);
- vaultHeroes = null;
- } finally {
- vaultLoading = false;
- }
-}
-
-interface DelverPlan {
- choice: HeroChoice;
- loadout: Loadout;
- look: Look;
- veteran: boolean;
-}
-
-function planFresh(chosen: Draft): DelverPlan {
- return {
- choice: freshChoice({ look: chosen.look, base: chosen.base, weapon: chosen.weapon, charm: chosen.charm }),
- loadout: loadoutOf(chosen),
- look: chosen.look,
- veteran: false,
- };
-}
-
-function planVeteran(hero: Hero): DelverPlan {
- return {
- choice: veteranChoice(hero.id),
- loadout: { base: hero.base, weapon: hero.weapon, charm: hero.charm, rank: rankForClears(hero.clears) },
- look: hero.look,
- veteran: true,
- };
-}
-
-/** Opens a run on chain so the contract issues the seed. Falls back to a practice run. */
-async function beginRun(plan: DelverPlan): Promise {
- if (starting) return;
- starting = true;
- displayedRelics = 0;
- resetRelicTravel();
- canvas.classList.remove('board-sunk');
- startStage = 'account';
- const openedAt = performance.now();
- const heroId = HERO_ORDER[plan.loadout.base] ?? 'lantern';
- descent = {
- art: plan.loadout.rank > 0 ? HEROES[heroId].veteranArt : HEROES[heroId].art,
- filter: cloakFilter(plan.look.cloak, plan.loadout.base),
- name: plan.look.name,
- emblem: plan.look.emblem,
- kit: [
- HEROES[heroId].name,
- WEAPONS[plan.loadout.weapon]?.name ?? '',
- CHARMS[plan.loadout.charm]?.name ?? '',
- ...(plan.loadout.rank > 0 ? [`Veteran rank ${plan.loadout.rank}`] : []),
- ].join(' · '),
- };
- chainStatus = '';
- chainTx = '';
- chainRank = null;
- chainHeroId = null;
- runWasVeteran = plan.veteran;
- try {
- const ticket = await withTimeout(
- enqueue(async () => {
- const api = await chain();
- const keypair = await api.getPlayerKeypair();
- startStage = 'opening';
- for (let attempt = 0; ; attempt += 1) {
- try {
- return await api.startRun(keypair, plan.choice);
- } catch (err) {
- const cooling = isContractError(err, CONTRACT_ERROR.StartCooldown);
- if (!cooling || attempt >= START_RETRIES) throw err;
- await sleep(START_RETRY_MS);
- }
- }
- }),
- START_TIMEOUT_MS
- );
- await sleep(Math.max(0, MIN_SCENE_MS - (performance.now() - openedAt)));
- startStage = 'ready';
- sfxDescend();
- await sleep(READY_HOLD_MS);
- resetMotion();
- startAmbience(1);
- showBanner('Floor 1', FLOOR_NAMES[1] ?? '');
- // The ticket is authoritative: the contract fixed the delver and the seed.
- const loadout: Loadout = { base: ticket.base, weapon: ticket.weapon, charm: ticket.charm, rank: ticket.rank };
- run = newRun(loadout, ticket.seed, ticket.look);
- practice = false;
- saveRun(run);
- } catch (err) {
- console.warn('Deepmark: could not open a verified run, starting a practice run', err);
- await sleep(Math.max(0, MIN_SCENE_MS - (performance.now() - openedAt)));
- startStage = 'offline';
- await sleep(READY_HOLD_MS + 600);
- resetMotion();
- startAmbience(1);
- showBanner('Floor 1', FLOOR_NAMES[1] ?? '');
- run = newRun(plan.loadout, challengeSeed ?? undefined, plan.look);
- challengeSeed = null;
- practice = true;
- } finally {
- starting = false;
- descent = null;
- }
- showHint('move');
-}
-
-/** Sends the action log. The contract replays it and decides the result. */
-const VERIFY_ATTEMPTS = 6;
-const VERIFY_RETRY_MS = 6_000;
-
-/** The contract's own refusals do not heal by waiting. Only TooFast and network trouble do. */
-function isPermanentFailure(err: unknown): boolean {
- if (!(err instanceof Error) || !err.message.includes('Error(Contract, #')) return false;
- return !isContractError(err, CONTRACT_ERROR.TooFast);
-}
-
-function describeFailure(err: unknown): string {
- if (isContractError(err, CONTRACT_ERROR.RunNotStarted)) {
- return 'This run is no longer open on Stellar. It was already settled or it expired.';
- }
- if (isContractError(err, CONTRACT_ERROR.NotHeroOwner)) return 'This delver changed hands during the run, so it cannot be settled.';
- if (
- isContractError(err, CONTRACT_ERROR.RunIncomplete) ||
- isContractError(err, CONTRACT_ERROR.TrailingActions) ||
- isContractError(err, CONTRACT_ERROR.InvalidAction)
- ) {
- return 'The contract could not reproduce this run, so it was not recorded.';
- }
- return 'Stellar could not be reached to settle this run.';
-}
-
-/** Sends the log, waiting out the contract's minimum play time and brief network trouble. */
-async function submitLog(finished: RunState): Promise {
- for (let attempt = 0; ; attempt += 1) {
- try {
- return await enqueue(async () => {
- const api = await chain();
- return api.finishRun(await api.getPlayerKeypair(), finished.actions);
- });
- } catch (err) {
- if (isPermanentFailure(err) || attempt >= VERIFY_ATTEMPTS - 1) throw err;
- chainStatus = 'Waiting for Stellar to settle this run...';
- verifyState = 'waiting';
- await sleep(VERIFY_RETRY_MS);
- }
- }
-}
-
-async function verifyOnChain(finished: RunState): Promise {
- chainStatus = 'Verifying on Stellar...';
- verifyState = 'pending';
- canRetry = false;
- let settled: FinishedRun;
- try {
- settled = await submitLog(finished);
- } catch (err) {
- console.warn('Deepmark: failed to verify this run on-chain', err);
- chainStatus = describeFailure(err);
- verifyState = 'failed';
- canRetry = !isPermanentFailure(err);
- failedRun = finished;
- // A refusal that waiting cannot fix means the run is gone for good, so there is nothing left to resume.
- if (isPermanentFailure(err)) clearSavedRun();
- return;
- }
-
- const { hash, outcome } = settled;
- failedRun = null;
- clearSavedRun();
- chainTx = hash;
- chainHeroId = outcome.hero_id ?? null;
- if (chainHeroId !== null) vaultHeroes = null;
- const relicsMatch = outcome.relics_common + outcome.relics_rare + outcome.relics_core === finished.relicsThisRun;
- const agrees =
- outcome.floor_reached === finished.floorNumber &&
- outcome.score === finished.scoreThisRun &&
- outcome.hp_left === finished.player.hp &&
- relicsMatch;
- if (!agrees) console.error('Deepmark: the contract result differs from the local simulation', outcome);
- chainStatus = agrees ? 'Verified on Stellar' : 'Verified on Stellar (this browser disagreed, the chain result stands)';
- verifyState = 'verified';
- justVerifiedUntil = performance.now() + 4000;
- if (chainHeroId !== null) window.setTimeout(sfxHeroEarned, 900);
-
- // The run is settled. Anything below only decorates the result screen, so a
- // failure here must never turn a confirmed transaction into an error.
- try {
- await refreshOnchain();
- const api = await chain();
- const top = runWasVeteran ? await api.fetchTopVeteran() : await api.fetchTopFresh();
- const position = top.findIndex((entry) => entry.player === playerAddress);
- chainRank = position >= 0 ? position + 1 : null;
- leaderboardRows = null;
- } catch (err) {
- console.warn('Deepmark: could not refresh the leaderboard rank', err);
- }
-}
-
-/** A run that grows past what the contract accepts can no longer be verified. */
-function endExhaustedRun(state: RunState): void {
- if (state.mode === 'result') return;
- state.mode = 'result';
- state.resultKind = 'dead';
- state.relicsThisRun = 0;
- state.scoreThisRun = 0;
- practice = true;
- clearSavedRun();
- chainStatus = 'This run grew too long to be verified.';
-}
-
-const DIRS: Partial> = {
- up: [0, -1],
- down: [0, 1],
- left: [-1, 0],
- right: [1, 0],
-};
-
-/** Books a finished run into this browser's own progress. The simulation stays free of storage so a log can be replayed safely. */
-function settleLocalProgress(finished: RunState): void {
- const extracted = finished.resultKind === 'extracted';
- const result = recordRun({
- floorReached: finished.floorNumber,
- relicsKept: finished.relicsThisRun,
- score: finished.scoreThisRun,
- extracted,
- heroId: finished.heroId,
- });
- progress = result.progress;
- finished.newBest = result.isNewBest && extracted;
-}
-
-/** Puts a run rebuilt from its saved log back on screen, at the point it was left. */
-function presentResumedRun(plan: Exclude, ticket: RunTicket): void {
- const resumed = plan.run;
- resetMotion();
- resetRelicTravel();
- canvas.classList.remove('board-sunk');
- chainStatus = '';
- chainTx = '';
- chainRank = null;
- chainHeroId = null;
- canRetry = false;
- practice = false;
- runWasVeteran = typeof ticket.veteran === 'number';
- displayedRelics = resumed.relicsThisRun;
- run = resumed;
- if (plan.kind === 'continue') {
- startAmbience(resumed.floorNumber);
- showBanner('Run restored', FLOOR_NAMES[resumed.floorNumber] ?? '');
- return;
- }
- // The run had already ended when the page went away: show its result and send the log.
- runId += 1;
- if (resumed.resultKind === 'dead') canvas.classList.add('board-sunk');
- else preloadShareAssets(HEROES[resumed.heroId].veteranArt, resumed.loadout);
- void verifyOnChain(resumed);
+function isLowHealth(): boolean {
+ const run = session.run;
+ if (!run || run.mode === 'result' || session.showLeaderboard || session.showStats) return false;
+ return run.player.hp / run.player.maxHp <= LOW_HEALTH_RATIO;
}
-let resumeChecked = false;
-
-/**
- * If this browser holds a saved run, asks the contract whether that very run is
- * still open and, if so, restores it. A saved run the contract no longer knows
- * is dropped. If Stellar cannot be read the save is kept for the next visit.
- */
-async function resumeOpenRun(): Promise {
- if (resumeChecked || chainRead !== 'ready') return;
- resumeChecked = true;
- const saved = loadSavedRun();
- if (!saved) return;
- try {
- const api = await chain();
- const ticket = await api.fetchOpenRun(playerAddress);
- if (run || starting) return;
- const plan = planResume(saved, ticket);
- if (plan.kind === 'discard' || !ticket) {
- clearSavedRun();
- return;
- }
- presentResumedRun(plan, ticket);
- } catch (err) {
- console.warn('Deepmark: could not check for an open run', err);
- }
+function pulseHeartbeatWhenLow(): void {
+ if (!isLowHealth()) return;
+ const now = performance.now();
+ if (now - session.lastHeartbeat <= HEARTBEAT_MS) return;
+ session.lastHeartbeat = now;
+ sfxHeartbeat();
}
-function handleEvent(event: GameEvent): void {
- switch (event.type) {
- case 'enemyHit': {
- if (run) {
- playAttack('player', run.player.x, run.player.y);
- const struck = run.enemies.findIndex((e) => e.x === event.x && e.y === event.y);
- if (struck >= 0) flashActor(`enemy-${struck}`);
- }
- spawnPopup(event.x, event.y, `-${event.amount}`, '#e5e7eb');
- spawnBurst(event.x, event.y, 'hit');
- sfxHitEnemy();
- hitStop(event.amount >= 5 ? 90 : 55);
- punch(Math.min(1, 0.25 + event.amount * 0.1));
- triggerShake(100 + event.amount * 12, 3 + event.amount);
- break;
- }
- case 'playerHit':
- if (run && run.activeEnemyIndex !== null) {
- const enemy = run.enemies[run.activeEnemyIndex];
- if (enemy) playAttack(`enemy-${run.activeEnemyIndex}`, enemy.x, enemy.y);
- }
- spawnPopup(event.x, event.y, `-${event.amount}`, '#ef5350');
- flashActor('player');
- spawnBurst(event.x, event.y, 'hit');
- sfxHitPlayer();
- hitStop(80);
- punch(0.5 + Math.min(0.5, event.amount * 0.1));
- triggerShake(150 + event.amount * 15, 5 + event.amount * 1.5);
- break;
- case 'enemyDied':
- spawnBurst(event.x, event.y, 'kill');
- hitStop(120);
- punch(0.8);
- sfxEnemyDied();
- triggerShake(120, 5);
- break;
- case 'relic': {
- spawnPopup(event.x, event.y, '+relic', '#ffca28');
- spawnBurst(event.x, event.y, 'relic');
- sfxRelic(event.kind);
- const offsetX = (canvas.width - GRID_WIDTH * TILE_SIZE) / 2;
- const startX = offsetX + event.x * TILE_SIZE + TILE_SIZE / 2;
- const startY = event.y * TILE_SIZE + TILE_SIZE / 2;
- const counter = document.getElementById('hud-relics');
- const canvasRect = canvas.getBoundingClientRect();
- if (counter && canvasRect.width > 0 && canvasRect.height > 0) {
- // The counter sits below the board in this layout: the point aims at the counter's
- // live horizontal position but can only ever reach the board's own bottom edge.
- const counterRect = counter.getBoundingClientRect();
- const scaleX = canvas.width / canvasRect.width;
- const scaleY = canvas.height / canvasRect.height;
- const endX = Math.min(canvas.width, Math.max(0, (counterRect.left + counterRect.width / 2 - canvasRect.left) * scaleX));
- const endY = Math.min(canvas.height - 4, (counterRect.top - canvasRect.top) * scaleY);
- spawnRelicTravel(startX, startY, endX, endY, event.kind);
- }
- break;
- }
- case 'floorCleared':
- sfxFloorCleared();
- showHint('choice');
- if (run && run.floorNumber === MAX_FLOOR - 1) preloadBossArt();
- break;
- case 'descended':
- resetMotion();
- sfxDescend();
- startAmbience(event.floor);
- if (event.floor >= MAX_FLOOR) {
- const stage = document.querySelector('.stage');
- if (stage) {
- bossIntro = playBossIntro(
- stage,
- 'Warden Prime',
- () => sfxBossIntro(),
- () => {
- bossIntro = null;
- }
- );
- } else {
- sfxBossIntro();
- }
- } else {
- showBanner(`Floor ${event.floor}`, FLOOR_NAMES[event.floor] ?? '', 'floor');
- sfxBanner();
- }
- break;
- case 'extracted':
- stopAmbience();
- sfxExtracted();
- if (run) settleLocalProgress(run);
- runId += 1;
- if (run) {
- const heroId = HERO_ORDER[run.loadout.base] ?? 'lantern';
- preloadShareAssets(HEROES[heroId].veteranArt, run.loadout);
- }
- if (run && !practice) void verifyOnChain(run);
- break;
- case 'dead':
- stopAmbience();
- sfxDead();
- triggerShake(250, 10);
- canvas.classList.add('board-sunk');
- if (run) settleLocalProgress(run);
- runId += 1;
- if (run && !practice) void verifyOnChain(run);
- break;
- case 'combatStart':
- showHint('combat');
- break;
- }
-}
-
-function flashShareButton(action: 'shareSave' | 'shareCopy' | 'shareChallenge', label: string): void {
- const btn = document.querySelector(`[data-action="${action}"]`);
- if (!btn) return;
- const original = btn.textContent ?? '';
- btn.textContent = label;
- window.setTimeout(() => {
- btn.textContent = original;
- }, 1600);
-}
-
-async function shareResult(target: RunState, mode: 'save' | 'copy'): Promise {
- const heroId = HERO_ORDER[target.loadout.base] ?? 'lantern';
- const blob = await renderShareCard({
- name: target.look.name,
- portraitArt: HEROES[heroId].veteranArt,
- portraitFilter: cloakFilter(target.look.cloak, target.loadout.base),
- weapon: target.loadout.weapon,
- charm: target.loadout.charm,
- floorReached: target.floorNumber,
- maxFloor: MAX_FLOOR,
- score: target.scoreThisRun,
- relics: target.relicsThisRun,
- rank: chainRank,
- txHash: chainTx,
- });
- if (!blob) return;
- if (mode === 'copy' && typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
- try {
- await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
- flashShareButton('shareCopy', 'Copied');
- return;
- } catch {
- // Clipboard image writes are not supported everywhere (notably iOS Safari): fall back to a download.
- }
- }
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = `deepmark-${target.look.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.png`;
- link.click();
- window.setTimeout(() => URL.revokeObjectURL(url), 2000);
- flashShareButton(mode === 'copy' ? 'shareCopy' : 'shareSave', 'Saved');
-}
-
-async function copyChallengeLink(target: RunState): Promise {
- const url = challengeUrl(target.seed, target.loadout);
- try {
- await navigator.clipboard.writeText(url);
- flashShareButton('shareChallenge', 'Copied');
- } catch {
- // No clipboard permission: the button simply does not confirm.
- }
-}
-
-/** Hovering Descend on the bank-or-push prompt: the heartbeat quickens the longer it holds. */
-let descendHovering = false;
-let descendHoverTimer: ReturnType | null = null;
-
-function scheduleDescendBeat(delay: number): void {
- descendHoverTimer = setTimeout(() => {
- if (!descendHovering) return;
- sfxHeartbeat();
- scheduleDescendBeat(Math.max(220, delay * 0.82));
- }, delay);
-}
-
-function onDescendHover(hovering: boolean): void {
- if (hovering === descendHovering) return;
- descendHovering = hovering;
- if (hovering) {
- sfxHeartbeat();
- scheduleDescendBeat(650);
- } else if (descendHoverTimer) {
- clearTimeout(descendHoverTimer);
- descendHoverTimer = null;
- }
-}
-
-const MOVE_CODES = {
- up: ActionCode.Up,
- down: ActionCode.Down,
- left: ActionCode.Left,
- right: ActionCode.Right,
-} as const;
-
-function syncMuteButton(): void {
- const button = document.getElementById('mute');
- if (!button) return;
- const muted = isMuted();
- button.textContent = muted ? 'Sound off' : 'Sound on';
- button.setAttribute('aria-pressed', muted ? 'true' : 'false');
-}
-
-syncMuteButton();
-
-onAction((action) => {
- if (action === 'mute') {
- toggleMuted();
- syncMuteButton();
- return;
- }
- if (!run) sfxUi();
- if (showStats) {
- if (action === 'stats' || action === 'cancel') showStats = false;
- if (action === 'replayHints') resetHints();
- return;
- }
-
- if (showLeaderboard) {
- if (action === 'leaderboard' || action === 'cancel') showLeaderboard = false;
- if (action === 'tabFresh' || action === 'tabVeteran') {
- boardTab = action === 'tabVeteran' ? 'veteran' : 'fresh';
- void loadBoard();
- }
- return;
- }
-
- if (starting) return;
-
- if (bossIntro) {
- bossIntro.skip();
- return;
- }
-
- if (showCreator) {
- if (action === 'cancel') showCreator = false;
- return;
- }
-
- if (showVault) {
- if (action === 'vault' || action === 'cancel') showVault = false;
- return;
- }
-
- if (!run) {
- if (action === 'confirm') {
- if (pendingChallengeDraft) {
- pendingChallengeDraft = false;
- } else {
- draft = loadDraft();
- }
- showCreator = true;
- }
- if (action === 'vault') void openVault();
- if (action === 'stats') showStats = true;
- if (action === 'leaderboard') void openLeaderboard();
- return;
- }
-
- if (run.mode === 'result') {
- const resultRun = run;
- if (action === 'confirm') {
- resetMotion();
- run = null;
- chainStatus = '';
- chainTx = '';
- chainRank = null;
- canRetry = false;
- }
- if (action === 'retryVerify' && failedRun) void verifyOnChain(failedRun);
- if (action === 'shareSave' || action === 'shareCopy') void shareResult(resultRun, action === 'shareCopy' ? 'copy' : 'save');
- if (action === 'shareChallenge') void copyChallengeLink(resultRun);
- if (action === 'vault') {
- resetMotion();
- run = null;
- chainStatus = '';
- chainTx = '';
- chainRank = null;
- canRetry = false;
- void openVault();
- }
- return;
- }
-
- const actionsBefore = run.actions.length;
- if (run.mode === 'combat') {
- if (action === 'confirm') applyAction(run, ActionCode.Attack);
- if (action === 'cancel') applyAction(run, ActionCode.Retreat);
- noteHintInput(action);
- } else if (run.mode === 'choice') {
- if (action === 'confirm') applyAction(run, ActionCode.Descend);
- if (action === 'cancel') applyAction(run, ActionCode.Extract);
- noteHintInput(action);
- } else {
- const dir = DIRS[action];
- if (dir) {
- if (dir[0] !== 0) face(dir[0] < 0 ? -1 : 1);
- const moved = applyAction(run, MOVE_CODES[action as keyof typeof MOVE_CODES]);
- if (moved && run.mode === 'explore') sfxStep();
- noteHintInput(action);
- }
- }
-
- if (run.exhausted) endExhaustedRun(run);
- if (!practice && run.actions.length !== actionsBefore) saveRun(run);
- for (const event of drainEvents(run)) handleEvent(event);
-});
-
-function frame(): void {
- if (run && run.mode !== 'result' && !showLeaderboard && !showStats && run.player.hp / run.player.maxHp <= LOW_HEALTH) {
- const now = performance.now();
- if (now - lastHeartbeat > HEARTBEAT_MS) {
- lastHeartbeat = now;
- sfxHeartbeat();
- }
- }
- const sunkDefeat = run && run.mode === 'result' && run.resultKind !== 'extracted' && !showLeaderboard && !showStats;
- if (run && !showLeaderboard && !showStats && run.mode !== 'result') drawWorld(ctx, run);
+function paintBoard(): void {
+ const { run, showLeaderboard, showStats } = session;
+ const covered = showLeaderboard || showStats;
+ const sunkDefeat = run && run.mode === 'result' && run.resultKind !== 'extracted' && !covered;
+ if (run && !covered && run.mode !== 'result') drawWorld(ctx, run);
else if (!sunkDefeat) {
- ctx.fillStyle = '#07080e';
+ ctx.fillStyle = BACKDROP_COLOR;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
+}
+
+/** Lets each relic's travelling point land on the counter, then bumps the counter for a core relic. */
+function landArrivedRelics(): void {
for (const kind of drainArrivedRelics()) {
- displayedRelics += 1;
+ session.displayedRelics += 1;
sfxRelicArrive(kind);
- if (kind === 'core') {
- window.setTimeout(() => {
- const node = document.getElementById('hud-relics');
- if (!node) return;
- node.classList.remove('bump');
- void node.offsetWidth;
- node.classList.add('bump');
- }, 140);
- }
+ if (kind !== 'core') continue;
+ window.setTimeout(() => {
+ const node = document.getElementById('hud-relics');
+ if (!node) return;
+ node.classList.remove('bump');
+ void node.offsetWidth;
+ node.classList.add('bump');
+ }, CORE_BUMP_DELAY_MS);
}
+}
+
+function syncView(): void {
+ const s = session;
syncChrome({
- showCreator,
- showVault,
- draft,
+ showCreator: s.showCreator,
+ showVault: s.showVault,
+ draft: s.draft,
creatorHooks: {
onBegin: (chosen) => {
- draft = chosen;
- showCreator = false;
+ s.draft = chosen;
+ s.showCreator = false;
void beginRun(planFresh(chosen));
},
onBack: () => {
- showCreator = false;
+ s.showCreator = false;
},
},
- vaultHeroes,
- vaultLoading,
+ vaultHeroes: s.vaultHeroes,
+ vaultLoading: s.vaultLoading,
onVeteran: (id) => {
- const hero = vaultHeroes?.find((h) => h.id === id);
+ const hero = s.vaultHeroes?.find((h) => h.id === id);
if (!hero) return;
- showVault = false;
+ s.showVault = false;
void beginRun(planVeteran(hero));
},
- boardTab,
- chainHeroId,
- canRetry,
- verifyState,
- runId,
- showLeaderboard,
- showStats,
- leaderboardLoading,
- leaderboardRows,
- displayRelics: displayedRelics,
- justVerified: performance.now() < justVerifiedUntil,
- playerAddress,
- run,
- progress,
+ boardTab: s.boardTab,
+ chainHeroId: s.chainHeroId,
+ chainClears: s.chainClears,
+ runWasVeteran: s.runWasVeteran,
+ canRetry: s.canRetry,
+ verifyState: s.verifyState,
+ runId: s.runId,
+ showLeaderboard: s.showLeaderboard,
+ showStats: s.showStats,
+ leaderboardLoading: s.leaderboardLoading,
+ leaderboardRows: s.leaderboardRows,
+ leaderboardFailed: s.leaderboardFailed,
+ recoveryNote: s.recoveryNote,
+ restoreStep: s.restoreStep,
+ restoreError: s.restoreError,
+ restoreTarget: s.restoreTarget,
+ displayRelics: s.displayedRelics,
+ justVerified: performance.now() < s.justVerifiedUntil,
+ playerAddress: s.playerAddress,
+ run: s.run,
+ progress: s.progress,
onchain: onchainSnapshot(),
- relics: relicBalances,
- chainRead,
- chainStatus,
- chainTx,
- chainRank,
- starting,
- startStage,
- descent,
- practice,
+ relics: s.relicBalances,
+ chainRead: s.chainRead,
+ chainStatus: s.chainStatus,
+ chainTx: s.chainTx,
+ chainRank: s.chainRank,
+ starting: s.starting,
+ startStage: s.startStage,
+ descent: s.descent,
+ practice: s.practice,
onDescendHover,
onSeal: sfxSeal,
});
- if (run?.mode !== 'choice' && descendHovering) onDescendHover(false);
+}
+
+function frame(): void {
+ const { run } = session;
+ if (run?.mode === 'result' && session.resultShownRunId !== session.runId) {
+ session.resultShownRunId = session.runId;
+ session.resultShownAt = performance.now();
+ }
+ pulseHeartbeatWhenLow();
+ paintBoard();
+ landArrivedRelics();
+ syncView();
+ if (session.run?.mode !== 'choice' && isDescendHovering()) onDescendHover(false);
requestAnimationFrame(frame);
}
diff --git a/client/src/game/delverOutcome.ts b/client/src/game/delverOutcome.ts
new file mode 100644
index 0000000..a39462f
--- /dev/null
+++ b/client/src/game/delverOutcome.ts
@@ -0,0 +1,21 @@
+/**
+ * What a verified full clear means for the delver, in words the result screen
+ * can trust. The contract reports a `hero_id` for both kinds of clear: a fresh
+ * delver is minted, while a veteran only gains a clear on the delver it
+ * already owns.
+ */
+export type DelverOutcome = { kind: 'none' } | { kind: 'earned' } | { kind: 'grew'; clears: number | null };
+
+export function delverOutcome(heroId: number | null, veteranRun: boolean, clears: number | null): DelverOutcome {
+ if (heroId === null) return { kind: 'none' };
+ if (!veteranRun) return { kind: 'earned' };
+ return { kind: 'grew', clears };
+}
+
+export const DELVER_EARNED_KICKER = 'A new delver is yours';
+
+/** The kicker for a veteran whose record grew, with the new clear count when it is known. */
+export function delverGrewKicker(clears: number | null): string {
+ if (clears === null) return "Your delver's record grew";
+ return `Your delver's record grew: ${clears} ${clears === 1 ? 'clear' : 'clears'}`;
+}
diff --git a/client/src/game/draft.ts b/client/src/game/draft.ts
index c8b58f6..a72c2e3 100644
--- a/client/src/game/draft.ts
+++ b/client/src/game/draft.ts
@@ -2,6 +2,7 @@ import { CHARMS, WEAPONS } from './items';
import { HERO_ORDER } from './heroes';
import { RECOMMENDED_KIT } from './kitDifficulty';
import { CLOAK_COUNT, EMBLEM_COUNT, isValidName, type Loadout, type Look } from './loadout';
+import { readJson, writeJson } from '../storage';
/** A delver being built in the creator, kept in this browser until a run starts. */
export interface Draft {
@@ -41,24 +42,12 @@ function sanitize(raw: unknown): Draft | null {
}
export function loadDraft(): Draft {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (raw) {
- const draft = sanitize(JSON.parse(raw));
- if (draft) return draft;
- }
- } catch {
- // Private mode or corrupt data: fall through to a fresh draft.
- }
- return defaultDraft();
+ // Missing or corrupt data falls through to a fresh draft.
+ return sanitize(readJson(STORAGE_KEY)) ?? defaultDraft();
}
export function saveDraft(draft: Draft): void {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
- } catch {
- // The draft still lasts for this tab.
- }
+ writeJson(STORAGE_KEY, draft);
}
export function loadoutOf(draft: Draft): Loadout {
diff --git a/client/src/game/items.ts b/client/src/game/items.ts
index d509313..991eaed 100644
--- a/client/src/game/items.ts
+++ b/client/src/game/items.ts
@@ -21,13 +21,37 @@ export interface Item extends Modifier {
}
export const WEAPONS: readonly Item[] = [
- { id: 0, name: 'Plumb Blade', icon: 'plumb-blade.webp', blurb: 'A sharper floor on every strike, at the cost of a thinner coat.', hp: -2, dmgMin: 1, dmgMax: 0 },
+ {
+ id: 0,
+ name: 'Plumb Blade',
+ icon: 'plumb-blade.webp',
+ blurb: 'A sharper floor on every strike, at the cost of a thinner coat.',
+ hp: -2,
+ dmgMin: 1,
+ dmgMax: 0,
+ },
{ id: 1, name: 'Reaver Hook', icon: 'reaver-hook.webp', blurb: 'Wild swings that can end a fight early.', hp: -2, dmgMin: 0, dmgMax: 1 },
- { id: 2, name: 'Bulwark Pike', icon: 'bulwark-pike.webp', blurb: 'A reach that keeps you standing, with softer blows.', hp: 1, dmgMin: 0, dmgMax: -1 },
+ {
+ id: 2,
+ name: 'Bulwark Pike',
+ icon: 'bulwark-pike.webp',
+ blurb: 'A reach that keeps you standing, with softer blows.',
+ hp: 1,
+ dmgMin: 0,
+ dmgMax: -1,
+ },
];
export const CHARMS: readonly Item[] = [
- { id: 0, name: 'Oil Flask', icon: 'oil-flask.webp', blurb: 'Slick and unsteady: hits vary more and you are a little sturdier.', hp: 1, dmgMin: -1, dmgMax: 1 },
+ {
+ id: 0,
+ name: 'Oil Flask',
+ icon: 'oil-flask.webp',
+ blurb: 'Slick and unsteady: hits vary more and you are a little sturdier.',
+ hp: 1,
+ dmgMin: -1,
+ dmgMax: 1,
+ },
{ id: 1, name: 'Keen Eye', icon: 'keen-eye.webp', blurb: 'Hits land far harder. You feel every wound.', hp: -3, dmgMin: 1, dmgMax: 2 },
{ id: 2, name: 'Stone Heart', icon: 'stone-heart.webp', blurb: 'A small, dependable boost to endurance.', hp: 1, dmgMin: 0, dmgMax: 0 },
];
diff --git a/client/src/game/kitDifficulty.ts b/client/src/game/kitDifficulty.ts
index a5bde88..112c715 100644
--- a/client/src/game/kitDifficulty.ts
+++ b/client/src/game/kitDifficulty.ts
@@ -33,16 +33,16 @@ export function difficultyOf(percent: number): Difficulty {
function bestKit(): Kit {
let best: Kit = { base: 0, weapon: 0, charm: 0 };
let bestPercent = -1;
- KIT_CLEAR_PERCENT.forEach((weapons, base) =>
- weapons.forEach((charms, weapon) =>
+ KIT_CLEAR_PERCENT.forEach((weapons, base) => {
+ weapons.forEach((charms, weapon) => {
charms.forEach((percent, charm) => {
if (percent > bestPercent) {
best = { base, weapon, charm };
bestPercent = percent;
}
- })
- )
- );
+ });
+ });
+ });
return best;
}
diff --git a/client/src/game/progress.ts b/client/src/game/progress.ts
index 75d9a4b..6e45f19 100644
--- a/client/src/game/progress.ts
+++ b/client/src/game/progress.ts
@@ -1,5 +1,6 @@
import { MAX_FLOOR } from './floor';
import type { HeroId } from './heroes';
+import { readJson, writeJson } from '../storage';
export interface Progress {
bestFloor: number;
@@ -49,22 +50,14 @@ function sanitize(raw: Partial | null): Progress {
}
export function loadProgress(): Progress {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return { ...EMPTY };
- return sanitize(JSON.parse(raw) as Partial);
- } catch {
- return { ...EMPTY };
- }
+ const stored = readJson(STORAGE_KEY);
+ if (!stored || typeof stored !== 'object') return { ...EMPTY };
+ return sanitize(stored as Partial);
}
function saveProgress(progress: Progress): void {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
- } catch {
- // localStorage unavailable (private mode, quota, etc). Progression just
- // won't persist across reloads this session; the run itself still works.
- }
+ // When storage is unavailable progression just does not persist; the run itself still works.
+ writeJson(STORAGE_KEY, progress);
}
export interface FinishedRun {
@@ -73,6 +66,8 @@ export interface FinishedRun {
score: number;
extracted: boolean;
heroId: HeroId;
+ /** A practice run is not recorded anywhere, so it never counts toward this browser's bests. */
+ practice: boolean;
}
function bump(value: number, amount: number): number {
@@ -81,6 +76,7 @@ function bump(value: number, amount: number): number {
export function recordRun(finished: FinishedRun): { progress: Progress; isNewBest: boolean } {
const previous = loadProgress();
+ if (finished.practice) return { progress: previous, isNewBest: false };
const floorReached = count(finished.floorReached, MAX_FLOOR);
const relicsKept = finished.extracted ? count(finished.relicsKept) : 0;
const score = finished.extracted ? count(finished.score) : 0;
diff --git a/client/src/game/recordNudge.ts b/client/src/game/recordNudge.ts
new file mode 100644
index 0000000..fac3d16
--- /dev/null
+++ b/client/src/game/recordNudge.ts
@@ -0,0 +1,12 @@
+export interface NudgeFacts {
+ practice: boolean;
+ verified: boolean;
+ won: boolean;
+ /** The account's verified run count on chain, or null while it is unknown. */
+ verifiedRuns: number | null;
+}
+
+/** The calm "keep your record" line: only after a fresh account's first run is verified, and only if it was a win. */
+export function shouldNudgeRecord(facts: NudgeFacts): boolean {
+ return !facts.practice && facts.verified && facts.won && facts.verifiedRuns === 1;
+}
diff --git a/client/src/game/savedRun.ts b/client/src/game/savedRun.ts
index b8df5f8..5eb6047 100644
--- a/client/src/game/savedRun.ts
+++ b/client/src/game/savedRun.ts
@@ -1,5 +1,6 @@
import { isValidLoadout, type Loadout, type Look } from './loadout';
import { ActionCode, applyAction, drainEvents, MAX_ACTIONS, newRun, type RunState } from './state';
+import { readItem, removeItem, writeItem } from '../storage';
/**
* A run the contract has opened but not yet settled, kept in this browser so a
@@ -75,6 +76,7 @@ export function parseSavedRun(raw: string): SavedRun | null {
try {
value = JSON.parse(raw);
} catch {
+ // Not JSON at all, so not a run this browser saved.
return null;
}
if (!value || typeof value !== 'object') return null;
@@ -97,28 +99,17 @@ export function serializeSavedRun(run: RunState): string {
}
export function saveRun(run: RunState): void {
- try {
- localStorage.setItem(STORAGE_KEY, serializeSavedRun(run));
- } catch {
- // Storage is unavailable (private mode, quota): the run still plays, it just cannot resume after a reload.
- }
+ // When storage is unavailable the run still plays, it just cannot resume after a reload.
+ writeItem(STORAGE_KEY, serializeSavedRun(run));
}
export function loadSavedRun(): SavedRun | null {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- return raw ? parseSavedRun(raw) : null;
- } catch {
- return null;
- }
+ const raw = readItem(STORAGE_KEY);
+ return raw ? parseSavedRun(raw) : null;
}
export function clearSavedRun(): void {
- try {
- localStorage.removeItem(STORAGE_KEY);
- } catch {
- // Nothing was stored, or storage is unavailable. Either way there is nothing to clear.
- }
+ removeItem(STORAGE_KEY);
}
/**
diff --git a/client/src/input/guard.ts b/client/src/input/guard.ts
new file mode 100644
index 0000000..440d933
--- /dev/null
+++ b/client/src/input/guard.ts
@@ -0,0 +1,10 @@
+/**
+ * Enter presses left over from combat must not dismiss the result screen the
+ * moment it appears, so confirm is ignored for this long after it shows.
+ */
+export const RESULT_INPUT_GUARD_MS = 700;
+
+/** True while input aimed at a freshly shown result screen should still be ignored. */
+export function resultGuardActive(shownAt: number, now: number): boolean {
+ return now - shownAt < RESULT_INPUT_GUARD_MS;
+}
diff --git a/client/src/input/keyboard.ts b/client/src/input/keyboard.ts
index cb4240f..8ea716e 100644
--- a/client/src/input/keyboard.ts
+++ b/client/src/input/keyboard.ts
@@ -10,6 +10,14 @@ export type Action =
| 'vault'
| 'mute'
| 'retryVerify'
+ | 'retryBoard'
+ | 'retryVault'
+ | 'again'
+ | 'copyRecovery'
+ | 'restoreRecovery'
+ | 'restoreCheck'
+ | 'restoreConfirm'
+ | 'restoreCancel'
| 'tabFresh'
| 'tabVeteran'
| 'pickLantern'
@@ -49,6 +57,14 @@ const ACTIONS: readonly Action[] = [
'vault',
'mute',
'retryVerify',
+ 'retryBoard',
+ 'retryVault',
+ 'again',
+ 'copyRecovery',
+ 'restoreRecovery',
+ 'restoreCheck',
+ 'restoreConfirm',
+ 'restoreCancel',
'tabFresh',
'tabVeteran',
'pickLantern',
diff --git a/client/src/loader.css b/client/src/loader.css
new file mode 100644
index 0000000..a9d5147
--- /dev/null
+++ b/client/src/loader.css
@@ -0,0 +1,75 @@
+/* Shared by the game and the stats page: both define --cyan, --line and --muted. */
+.loader {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 0;
+}
+
+.loader-mark {
+ width: 56px;
+ height: 56px;
+ display: block;
+ animation: loader-breathe 2.4s ease-in-out infinite;
+}
+
+.loader-mark .seal-icon {
+ width: 100%;
+ height: 100%;
+ display: block;
+ filter: drop-shadow(0 0 10px rgba(126, 240, 224, 0.4));
+}
+
+.loader-mark .seal-outer,
+.loader-mark .seal-inner,
+.loader-mark .seal-eye {
+ fill: none;
+ stroke: var(--cyan);
+ stroke-width: 2;
+ stroke-linejoin: round;
+}
+
+.loader-mark .seal-pupil { fill: var(--cyan); }
+
+.loader-text {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.92rem;
+ letter-spacing: 0.04em;
+ text-align: center;
+}
+
+.loader-rows {
+ align-self: stretch;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.loader-row {
+ display: block;
+ height: 44px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: linear-gradient(90deg, rgba(232, 224, 208, 0.04) 20%, rgba(126, 240, 224, 0.1) 50%, rgba(232, 224, 208, 0.04) 80%) 0 0 / 200% 100%;
+ animation: loader-sweep 1.8s ease-in-out infinite;
+}
+
+.loader-row:nth-child(2) { animation-delay: 150ms; }
+.loader-row:nth-child(3) { animation-delay: 300ms; }
+
+@keyframes loader-breathe {
+ 0%, 100% { opacity: 0.55; transform: scale(0.96); }
+ 50% { opacity: 1; transform: scale(1); }
+}
+
+@keyframes loader-sweep {
+ from { background-position: 100% 0; }
+ to { background-position: -100% 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .loader-mark { animation: none; }
+ .loader-row { animation: none; }
+}
diff --git a/client/src/main.ts b/client/src/main.ts
index 7e525bb..a3153ea 100644
--- a/client/src/main.ts
+++ b/client/src/main.ts
@@ -34,7 +34,7 @@ if (previewDescent) {
}
});
} else if (previewResult) {
- // http://localhost:5173/?preview=result&hero=1&loss=1
+ // http://localhost:5173/?preview=result&hero=1 (new delver) or hero=vet (veteran clear) &loss=1
const params = new URLSearchParams(window.location.search);
// Mounted in the real shell, so landmarks and styles match the game.
const host = document.getElementById('overlay') ?? document.body;
@@ -60,6 +60,7 @@ if (previewDescent) {
rank: 2,
txUrl: 'https://stellar.expert/explorer/testnet',
heroEarned: params.get('hero') === '1',
+ delverGrew: params.get('hero') === 'vet' ? { clears: 3 } : null,
canRetry: false,
});
}, 4500);
diff --git a/client/src/preview/screens.ts b/client/src/preview/screens.ts
index aec698a..4c06ac5 100644
--- a/client/src/preview/screens.ts
+++ b/client/src/preview/screens.ts
@@ -5,7 +5,9 @@
* ?preview=vault a vault with two earned delvers
* ?preview=leaderboard a leaderboard with the player in second place
*/
-import { leaderboardCard, vaultCard, type LeaderboardRow } from '../render/chrome';
+import type { LeaderboardRow } from '../render/chromeTypes';
+import { leaderboardCard } from '../render/leaderboardCard';
+import { vaultCard } from '../render/vaultCard';
import type { Hero } from '../stellar/contract';
const SAMPLE_PLAYER = 'GCDEEPMARKSAMPLEPLAYERADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAB';
@@ -40,9 +42,25 @@ const SAMPLE_HEROES: Hero[] = [
];
const SAMPLE_ROWS: LeaderboardRow[] = [
- { address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', name: 'Corvin', emblem: 0, score: 7, floor: 3, hpLeft: 4, actions: 98 },
+ {
+ address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ name: 'Corvin',
+ emblem: 0,
+ score: 7,
+ floor: 3,
+ hpLeft: 4,
+ actions: 98,
+ },
{ address: SAMPLE_PLAYER, name: 'Brannoch', emblem: 1, score: 6, floor: 3, hpLeft: 3, actions: 112 },
- { address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', name: 'Isolde', emblem: 3, score: 4, floor: 2, hpLeft: 0, actions: 131 },
+ {
+ address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
+ name: 'Isolde',
+ emblem: 3,
+ score: 4,
+ floor: 2,
+ hpLeft: 0,
+ actions: 131,
+ },
];
export type ScreenPreview = 'vault' | 'leaderboard';
@@ -62,6 +80,7 @@ export function mountScreenPreview(kind: ScreenPreview): void {
: leaderboardCard({
boardTab: 'fresh',
leaderboardLoading: false,
+ leaderboardFailed: false,
leaderboardRows: SAMPLE_ROWS,
playerAddress: SAMPLE_PLAYER,
justVerified: false,
diff --git a/client/src/render/audio.ts b/client/src/render/audio.ts
index b086224..c0356da 100644
--- a/client/src/render/audio.ts
+++ b/client/src/render/audio.ts
@@ -3,12 +3,14 @@
* download, nothing to license. A quiet ambience per floor, soft footsteps,
* hits with a little variation each time, and bell tones for relics.
*/
+import { readItem, writeItem } from '../storage';
+
const MUTE_KEY = 'deepmark:muted:v1';
const FX_VOLUME_KEY = 'deepmark:vol:fx:v1';
const AMBIENCE_VOLUME_KEY = 'deepmark:vol:ambience:v1';
const MASTER_LEVEL = 0.9;
/** A ceremony sound ducks the ambience by about 6 dB for its length. */
-const DUCK_FACTOR = Math.pow(10, -6 / 20);
+const DUCK_FACTOR = 10 ** (-6 / 20);
let ctx: AudioContext | null = null;
let master: GainNode | null = null;
@@ -22,22 +24,14 @@ let fxVolume = readVolume(FX_VOLUME_KEY);
let ambienceVolume = readVolume(AMBIENCE_VOLUME_KEY);
function readMuted(): boolean {
- try {
- return localStorage.getItem(MUTE_KEY) === '1';
- } catch {
- return false;
- }
+ return readItem(MUTE_KEY) === '1';
}
function readVolume(key: string): number {
- try {
- const raw = localStorage.getItem(key);
- if (raw === null) return 1;
- const value = Number(raw);
- return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1;
- } catch {
- return 1;
- }
+ const raw = readItem(key);
+ if (raw === null) return 1;
+ const value = Number(raw);
+ return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1;
}
/**
@@ -107,11 +101,7 @@ export function isMuted(): boolean {
export function toggleMuted(): boolean {
muted = !muted;
- try {
- localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
- } catch {
- // The choice still holds for this tab.
- }
+ writeItem(MUTE_KEY, muted ? '1' : '0');
const audio = getContext();
if (audio && master) master.gain.setTargetAtTime(muted ? 0 : MASTER_LEVEL, audio.currentTime, 0.05);
return muted;
@@ -127,22 +117,14 @@ export function getAmbienceVolume(): number {
export function setFxVolume(value: number): void {
fxVolume = Math.min(1, Math.max(0, value));
- try {
- localStorage.setItem(FX_VOLUME_KEY, String(fxVolume));
- } catch {
- // The choice still holds for this tab.
- }
+ writeItem(FX_VOLUME_KEY, String(fxVolume));
const audio = getContext();
if (audio && fxGain) fxGain.gain.setTargetAtTime(fxVolume, audio.currentTime, 0.05);
}
export function setAmbienceVolume(value: number): void {
ambienceVolume = Math.min(1, Math.max(0, value));
- try {
- localStorage.setItem(AMBIENCE_VOLUME_KEY, String(ambienceVolume));
- } catch {
- // The choice still holds for this tab.
- }
+ writeItem(AMBIENCE_VOLUME_KEY, String(ambienceVolume));
const audio = getContext();
if (audio && ambienceGain) ambienceGain.gain.setTargetAtTime(ambienceVolume, audio.currentTime, 0.05);
}
@@ -227,7 +209,11 @@ function bell(freq: number, gainLevel: number, delay = 0, decay = 1.1, destinati
const dest = destination ?? out();
if (!audio || !dest) return;
const t = audio.currentTime + delay;
- for (const [ratio, level] of [[1, 1], [2.76, 0.45], [5.4, 0.2]] as const) {
+ for (const [ratio, level] of [
+ [1, 1],
+ [2.76, 0.45],
+ [5.4, 0.2],
+ ] as const) {
const osc = audio.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq * ratio, t);
@@ -357,7 +343,11 @@ function makeBed(floor: number): Bed | null {
const stoppers: (() => void)[] = [];
- for (const [freq, gainValue] of [[spec.root, 0.07], [spec.root * 1.003, 0.05], [spec.tension, 0.025]] as const) {
+ for (const [freq, gainValue] of [
+ [spec.root, 0.07],
+ [spec.root * 1.003, 0.05],
+ [spec.tension, 0.025],
+ ] as const) {
const osc = audio.createOscillator();
osc.type = 'sine';
osc.frequency.value = freq;
@@ -373,7 +363,10 @@ function makeBed(floor: number): Bed | null {
gain.connect(level);
osc.start();
lfo.start();
- stoppers.push(() => osc.stop(), () => lfo.stop());
+ stoppers.push(
+ () => osc.stop(),
+ () => lfo.stop()
+ );
}
const wind = audio.createBufferSource();
@@ -396,7 +389,10 @@ function makeBed(floor: number): Bed | null {
windGain.connect(level);
wind.start();
sweep.start();
- stoppers.push(() => wind.stop(), () => sweep.stop());
+ stoppers.push(
+ () => wind.stop(),
+ () => sweep.stop()
+ );
return {
stop: () => {
diff --git a/client/src/render/bossIntro.ts b/client/src/render/bossIntro.ts
index 0c0d6f1..befa611 100644
--- a/client/src/render/bossIntro.ts
+++ b/client/src/render/bossIntro.ts
@@ -69,7 +69,7 @@ export function playBossIntro(host: HTMLElement, name: string, onSting: () => vo
function finish(): void {
if (done) return;
done = true;
- timers.forEach((timer) => window.clearTimeout(timer));
+ for (const timer of timers) window.clearTimeout(timer);
scene.remove();
onDone();
}
diff --git a/client/src/render/canvas.ts b/client/src/render/canvas.ts
index 101623a..953e1a6 100644
--- a/client/src/render/canvas.ts
+++ b/client/src/render/canvas.ts
@@ -74,7 +74,15 @@ function biome(floor: number): { floor: HTMLImageElement; wall: HTMLImageElement
return { floor: art.floor, wall: art.wall, stairs: art.stairs };
}
-function pickFrame(idle: HTMLImageElement, walkA: HTMLImageElement, walkB: HTMLImageElement, attack: HTMLImageElement, clip: Clip, phase: number, shift: boolean): HTMLImageElement {
+function pickFrame(
+ idle: HTMLImageElement,
+ walkA: HTMLImageElement,
+ walkB: HTMLImageElement,
+ attack: HTMLImageElement,
+ clip: Clip,
+ phase: number,
+ shift: boolean
+): HTMLImageElement {
if (clip === 'attack') {
if (ready(attack)) return attack;
if (ready(walkA)) return walkA;
@@ -125,14 +133,7 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
ctx.closePath();
}
-function drawBar(
- ctx: CanvasRenderingContext2D,
- cx: number,
- topY: number,
- width: number,
- ratio: number,
- lowThreshold: number
-): void {
+function drawBar(ctx: CanvasRenderingContext2D, cx: number, topY: number, width: number, ratio: number, lowThreshold: number): void {
const height = 5;
roundRect(ctx, cx - width / 2, topY, width, height, 2);
ctx.fillStyle = COLOR_HPBACK;
@@ -144,30 +145,16 @@ function drawBar(
ctx.fill();
}
-function blitCell(
- ctx: CanvasRenderingContext2D,
- image: HTMLImageElement,
- x: number,
- y: number,
- dx: number,
- dy: number
-): void {
+function blitCell(ctx: CanvasRenderingContext2D, image: HTMLImageElement, x: number, y: number, dx: number, dy: number): void {
const sx = (x / GRID_WIDTH) * image.width;
const sy = (y / GRID_HEIGHT) * image.height;
- ctx.drawImage(
- image,
- sx,
- sy,
- image.width / GRID_WIDTH,
- image.height / GRID_HEIGHT,
- dx,
- dy,
- TILE_SIZE,
- TILE_SIZE
- );
+ ctx.drawImage(image, sx, sy, image.width / GRID_WIDTH, image.height / GRID_HEIGHT, dx, dy, TILE_SIZE, TILE_SIZE);
}
-function playerFrames(heroId: HeroId, veteran: boolean): {
+function playerFrames(
+ heroId: HeroId,
+ veteran: boolean
+): {
idle: HTMLImageElement;
walkA: HTMLImageElement;
walkB: HTMLImageElement;
@@ -209,13 +196,7 @@ function drawFooted(
ctx.restore();
}
-function drawCentered(
- ctx: CanvasRenderingContext2D,
- image: HTMLImageElement,
- cx: number,
- cy: number,
- size: number
-): void {
+function drawCentered(ctx: CanvasRenderingContext2D, image: HTMLImageElement, cx: number, cy: number, size: number): void {
ctx.drawImage(image, cx - size / 2, cy - size / 2, size, size);
}
@@ -276,13 +257,7 @@ function paintBoard(ctx: CanvasRenderingContext2D, run: RunState, offsetX: numbe
}
}
-function shadeOpenEdge(
- ctx: CanvasRenderingContext2D,
- dx: number,
- dy: number,
- side: 'n' | 's' | 'e' | 'w',
- blocked: boolean
-): void {
+function shadeOpenEdge(ctx: CanvasRenderingContext2D, dx: number, dy: number, side: 'n' | 's' | 'e' | 'w', blocked: boolean): void {
if (!blocked) return;
const depth = 16;
let gradient: CanvasGradient;
@@ -451,19 +426,9 @@ export function drawWorld(ctx: CanvasRenderingContext2D, run: RunState): void {
const playerPose = sampleActor('player', run.player.x, run.player.y);
const player = cell(playerPose.x, playerPose.y);
const sheet = playerFrames(run.heroId, run.loadout.rank > 0);
- const playerImage = pickFrame(
- sheet.idle,
- sheet.walkA,
- sheet.walkB,
- sheet.attack,
- playerPose.clip,
- playerPose.phase,
- false
- );
+ const playerImage = pickFrame(sheet.idle, sheet.walkA, sheet.walkB, sheet.attack, playerPose.clip, playerPose.phase, false);
const playerLunge = playerPose.clip === 'attack' ? Math.sin(playerPose.phase * Math.PI) * 12 * facing : 0;
- const squat = run.heroId === 'cinder' && playerPose.clip === 'walk'
- ? 1 - Math.sin(playerPose.phase * Math.PI) * 0.08
- : 1;
+ const squat = run.heroId === 'cinder' && playerPose.clip === 'walk' ? 1 - Math.sin(playerPose.phase * Math.PI) * 0.08 : 1;
if (ready(playerImage)) {
// The cloak colour recolours the delver. Browsers without canvas filters just draw the base art.
const cloak = cloakFilter(run.look.cloak, run.loadout.base);
@@ -471,8 +436,7 @@ export function drawWorld(ctx: CanvasRenderingContext2D, run: RunState): void {
ctx.filter = cloak === 'none' ? flash.trim() || 'none' : `${cloak}${flash}`;
drawFooted(ctx, playerImage, player.cx + playerLunge, player.cy, ACTOR_SIZE.player, facing < 0, squat);
ctx.filter = 'none';
- }
- else {
+ } else {
ctx.fillStyle = '#4fc3f7';
ctx.beginPath();
ctx.arc(player.cx, player.cy, 16, 0, Math.PI * 2);
diff --git a/client/src/render/chrome.ts b/client/src/render/chrome.ts
index 126306f..d1ff665 100644
--- a/client/src/render/chrome.ts
+++ b/client/src/render/chrome.ts
@@ -1,85 +1,22 @@
-import { ENEMIES } from '../game/enemies';
-import { buildFloor, MAX_FLOOR, type RelicKind } from '../game/floor';
+import { MAX_FLOOR } from '../game/floor';
import { HEROES, HERO_ORDER } from '../game/heroes';
-import type { Progress } from '../game/progress';
import type { RunState } from '../game/state';
-import type { Draft } from '../game/draft';
-import { CHARMS, WEAPONS } from '../game/items';
-import { MAX_RANK } from '../game/loadout';
+import { LOW_HEALTH_RATIO } from '../constants';
import { STELLAR_CONFIG } from '../stellar/config';
-import type { Hero } from '../stellar/contract';
-import { getAmbienceVolume, getFxVolume, setAmbienceVolume, setFxVolume } from './audio';
-import { mountCreator, type CreatorHooks } from './creator';
-import { cloakFilter, emblemIcon } from './emblems';
-import { mountResult, updateVerification, type ResultSummary, type Verification, type VerifyState } from './result';
-import { mountDescent, setDescentStage, type DescentOptions, type DescentStage } from './descent';
-import { mountTitle, updateTitle, type TitleInfo } from './title';
import type { RelicBalances } from '../stellar/relics';
-
-export interface LeaderboardRow {
- address: string;
- name: string;
- emblem: number;
- score: number;
- floor: number;
- hpLeft: number;
- actions: number;
-}
-
-export type ChainRead = 'loading' | 'ready' | 'unavailable';
-
-export interface OnchainSnapshot {
- bestFloor: number;
- bestScore: number;
- runs: number;
- extractions: number;
- deaths: number;
- forfeits: number;
- relicsBanked: number;
-}
-
-export type BoardTab = 'fresh' | 'veteran';
-
-export interface ChromeView {
- showCreator: boolean;
- showVault: boolean;
- draft: Draft;
- creatorHooks: CreatorHooks;
- vaultHeroes: Hero[] | null;
- vaultLoading: boolean;
- onVeteran: (id: number) => void;
- boardTab: BoardTab;
- chainHeroId: number | null;
- canRetry: boolean;
- verifyState: VerifyState;
- runId: number;
- showLeaderboard: boolean;
- showStats: boolean;
- leaderboardLoading: boolean;
- leaderboardRows: LeaderboardRow[] | null;
- displayRelics: number;
- justVerified: boolean;
- playerAddress: string;
- run: RunState | null;
- progress: Progress;
- onchain: OnchainSnapshot | null;
- relics: RelicBalances | null;
- chainRead: ChainRead;
- chainStatus: string;
- chainTx: string;
- chainRank: number | null;
- starting: boolean;
- startStage: DescentStage;
- descent: DescentOptions | null;
- practice: boolean;
- onDescendHover: (hovering: boolean) => void;
- /** Fired once, synchronously with the seal's strike animation, when a run is verified. */
- onSeal: () => void;
-}
-
-/** The slice of the view each card reads, so a card can be mounted on its own for previews and checks. */
-export type VaultView = Pick;
-export type LeaderboardView = Pick;
+import type { ChromeView } from './chromeTypes';
+import { isTouchFirst, setBumpText, setText } from './dom';
+import { mountCreator } from './creator';
+import { cloakFilter } from './emblems';
+import { leaderboardCard } from './leaderboardCard';
+import { combatCard, choiceCard } from './promptCards';
+import { mountResult, updateVerification, type ResultSummary, type Verification } from './result';
+import { mountDescent, setDescentStage } from './descent';
+import { delverOutcome } from '../game/delverOutcome';
+import { shouldNudgeRecord } from '../game/recordNudge';
+import { statsCard } from './statsCard';
+import { mountTitle, updateTitle, type TitleInfo } from './title';
+import { vaultCard } from './vaultCard';
const TX_HASH = /^[a-f0-9]{64}$/;
@@ -92,49 +29,6 @@ function relicSummary(relics: RelicBalances | null): string {
return parts.join(', ');
}
-function el(
- tag: K,
- className?: string,
- text?: string
-): HTMLElementTagNameMap[K] {
- const node = document.createElement(tag);
- if (className) node.className = className;
- if (text !== undefined) node.textContent = text;
- return node;
-}
-
-function button(label: string, action: string, className = 'btn'): HTMLButtonElement {
- const node = el('button', className, label);
- node.type = 'button';
- node.dataset.action = action;
- return node;
-}
-
-function shortenAddress(address: string): string {
- if (address.length < 10) return address;
- return `${address.slice(0, 4)}...${address.slice(-4)}`;
-}
-
-function setText(id: string, value: string): void {
- const node = document.getElementById(id);
- if (node && node.textContent !== value) node.textContent = value;
-}
-
-/** Sets text and, if it changed, replays a small pop so gains are felt. */
-function setBumpText(id: string, value: string): void {
- const node = document.getElementById(id);
- if (!node || node.textContent === value) return;
- const first = node.textContent === '';
- node.textContent = value;
- if (first) return;
- node.classList.remove('bump');
- void node.offsetWidth;
- node.classList.add('bump');
-}
-
-/** The bar turns red at or below this share of max health. A low state is also spoken, so colour is not the only cue. */
-const LOW_HEALTH_RATIO = 0.34;
-
function paintHp(hp: number, max: number): void {
const ratio = max > 0 ? Math.max(0, Math.min(1, hp / max)) : 0;
const fill = document.getElementById('hp-fill');
@@ -217,7 +111,7 @@ function renderOverlay(view: ChromeView): void {
const info = titleInfo(view);
if (overlay.querySelector('.title-v2')) updateTitle(info);
else mountTitle(overlay, info);
- if (PANEL_KEYS.some((panel) => previousKey.startsWith(panel))) {
+ if (!isTouchFirst() && PANEL_KEYS.some((panel) => previousKey.startsWith(panel))) {
overlay.querySelector('.menu-item.is-primary')?.focus({ preventScroll: true });
}
return;
@@ -250,10 +144,8 @@ function overlaySignature(view: ChromeView): string {
return `vault:${view.vaultLoading}:${ids ?? ''}`;
}
if (view.showLeaderboard) {
- const rows = view.leaderboardRows
- ?.map((row) => `${row.address}:${row.score}:${row.floor}:${row.hpLeft}:${row.actions}`)
- .join('|');
- return `board:${view.boardTab}:${view.leaderboardLoading}:${rows ?? ''}:${view.playerAddress}`;
+ const rows = view.leaderboardRows?.map((row) => `${row.address}:${row.score}:${row.floor}:${row.hpLeft}:${row.actions}`).join('|');
+ return `board:${view.boardTab}:${view.leaderboardLoading}:${view.leaderboardFailed}:${rows ?? ''}:${view.playerAddress}`;
}
if (view.showStats) {
const progress = view.progress;
@@ -261,7 +153,7 @@ function overlaySignature(view: ChromeView): string {
? `${view.onchain.bestFloor}:${view.onchain.bestScore}:${view.onchain.runs}:${view.onchain.extractions}:${view.onchain.deaths}:${view.onchain.forfeits}:${view.onchain.relicsBanked}`
: '';
const vault = view.relics ? `${view.relics.common}:${view.relics.rare}:${view.relics.core}` : '';
- return `stats:${view.chainRead}:${chain}:${vault}:${view.playerAddress}:${progress.runsPlayed}:${progress.extractions}:${progress.deaths}:${progress.bestFloor}:${progress.bestRelics}:${progress.bestScore}:${progress.relicsKept}:${progress.lanternRuns}:${progress.cinderRuns}`;
+ return `stats:${view.restoreStep}:${view.restoreError}:${view.restoreTarget}:${view.recoveryNote}:${view.chainRead}:${chain}:${vault}:${view.playerAddress}:${progress.runsPlayed}:${progress.extractions}:${progress.deaths}:${progress.bestFloor}:${progress.bestRelics}:${progress.bestScore}:${progress.relicsKept}:${progress.lanternRuns}:${progress.cinderRuns}`;
}
if (!view.run) return 'title';
if (view.run.mode === 'result') {
@@ -280,330 +172,18 @@ function overlaySignature(view: ChromeView): string {
function titleInfo(view: ChromeView): TitleInfo {
const progress = view.progress;
- const local = progress.runsPlayed > 0
- ? `Best floor ${progress.bestFloor} / ${MAX_FLOOR} · Best relics ${progress.bestRelics} · Runs ${progress.runsPlayed}`
- : '';
- const chain = view.onchain && view.onchain.runs > 0
- ? `Verified on Stellar: best score ${view.onchain.bestScore}, floor ${view.onchain.bestFloor} / ${MAX_FLOOR}, ${view.onchain.runs} runs`
- : '';
+ const local =
+ progress.runsPlayed > 0
+ ? `Best floor ${progress.bestFloor} / ${MAX_FLOOR} · Best relics ${progress.bestRelics} · Runs ${progress.runsPlayed}`
+ : '';
+ const chain =
+ view.onchain && view.onchain.runs > 0
+ ? `Verified on Stellar: best score ${view.onchain.bestScore}, floor ${view.onchain.bestFloor} / ${MAX_FLOOR}, ${view.onchain.runs} runs`
+ : '';
const held = relicSummary(view.relics);
return { local, chain, vault: held ? `Relics in your vault: ${held}` : '' };
}
-export function leaderboardCard(view: LeaderboardView): HTMLElement {
- const card = el('div', 'panel panel-scroll');
- card.append(el('h2', 'panel-title', 'Leaderboard'));
- card.append(
- el('p', 'panel-sub', 'Best verified runs. The contract replays every run before it is ranked.')
- );
-
- const tabs = el('div', 'tab-row');
- const fresh = button('Fresh delvers', 'tabFresh', view.boardTab === 'fresh' ? 'btn btn-primary' : 'btn');
- const veteran = button('Veterans', 'tabVeteran', view.boardTab === 'veteran' ? 'btn btn-primary' : 'btn');
- fresh.setAttribute('aria-pressed', view.boardTab === 'fresh' ? 'true' : 'false');
- veteran.setAttribute('aria-pressed', view.boardTab === 'veteran' ? 'true' : 'false');
- tabs.append(fresh, veteran);
- card.append(tabs);
-
- if (view.leaderboardLoading) {
- card.append(el('p', 'panel-body', 'Loading records...'));
- } else if (!view.leaderboardRows || view.leaderboardRows.length === 0) {
- card.append(
- el('p', 'panel-body', view.boardTab === 'veteran' ? 'No veteran has descended yet.' : 'No verified runs yet. Be the first.')
- );
- } else {
- const table = el('ol', 'board');
- view.leaderboardRows.forEach((row, index) => {
- const mine = row.address === view.playerAddress;
- const fresh = mine && view.justVerified;
- const item = el('li', mine ? 'board-row is-you' : 'board-row');
- if (fresh) item.classList.add('is-fresh');
- item.style.animationDelay = `${index * 60}ms`;
- const rank = el('span', 'board-rank', `${index + 1}`);
- if (fresh) rank.style.animationDelay = `${index * 60 + 180}ms`;
- item.append(rank);
- const who = el('span', 'board-who');
- who.append(emblemIcon(row.emblem, 'emblem emblem-small'));
- who.append(el('span', 'board-name', mine ? `${row.name} (you)` : row.name));
- who.append(el('span', 'board-addr', shortenAddress(row.address)));
- item.append(who);
- item.append(el('span', 'board-stat', `${row.score}`));
- item.append(el('span', 'board-stat', `${row.floor}/${MAX_FLOOR}`));
- item.append(el('span', 'board-stat', `${row.hpLeft}`));
- item.append(el('span', 'board-stat', `${row.actions}`));
- table.append(item);
- });
- const head = el('div', 'board-head');
- head.append(el('span', '', '#'));
- head.append(el('span', '', 'Delver'));
- head.append(el('span', '', 'Score'));
- head.append(el('span', '', 'Floor'));
- head.append(el('span', '', 'HP'));
- head.append(el('span', '', 'Moves'));
- card.append(head, table);
- }
-
- card.append(button('Back', 'cancel', 'btn'));
- return card;
-}
-
-export function vaultCard(view: VaultView): HTMLElement {
- const wrap = el('div', 'vault-view');
- wrap.append(el('div', 'vault-backdrop'));
-
- const card = el('div', 'panel panel-scroll panel-wide');
- card.append(el('h2', 'panel-title', 'Your vault'));
- card.append(
- el(
- 'p',
- 'panel-sub',
- 'Delvers you earned by clearing all three floors. Each is a token on Stellar that you own, and each can descend again as a veteran.'
- )
- );
-
- if (view.vaultLoading || !view.vaultHeroes) {
- card.append(el('p', 'panel-body', view.vaultLoading ? 'Opening the vault...' : 'The vault could not be read.'));
- } else if (view.vaultHeroes.length === 0) {
- card.append(el('p', 'panel-body', 'Empty for now. Clear all three floors with a new delver to claim one.'));
- } else {
- const list = el('div', 'vault-list');
- view.vaultHeroes.forEach((hero, index) => {
- const item = heroCard(hero, view);
- item.style.animationDelay = `${index * 60}ms`;
- list.append(item);
- });
- card.append(list);
- }
-
- card.append(button('Back', 'cancel', 'btn'));
- wrap.append(card);
- return wrap;
-}
-
-function heroCard(hero: Hero, view: VaultView): HTMLElement {
- const item = el('article', 'vault-card');
- const baseId = HERO_ORDER[hero.base] ?? 'lantern';
- const portrait = el('img', 'vault-portrait');
- portrait.src = `/art/${HEROES[baseId].veteranArt}`;
- portrait.alt = '';
- portrait.style.filter = cloakFilter(hero.look.cloak, hero.base);
- const copy = el('div', 'vault-copy');
- const title = el('p', 'vault-name');
- title.append(emblemIcon(hero.look.emblem, 'emblem emblem-small'), el('span', '', hero.look.name));
- const kit = `${HEROES[baseId].name} · ${WEAPONS[hero.weapon]?.name ?? '?'} · ${CHARMS[hero.charm]?.name ?? '?'}`;
- const icons = el('span', 'vault-icons');
- for (const file of [WEAPONS[hero.weapon]?.icon, CHARMS[hero.charm]?.icon]) {
- if (!file) continue;
- const icon = el('img', 'vault-icon');
- icon.src = `/art/${file}`;
- icon.alt = '';
- icons.append(icon);
- }
- const rank = Math.min(hero.clears, MAX_RANK);
- const pips = el('span', 'vault-pips');
- for (let i = 0; i < MAX_RANK; i += 1) pips.append(el('span', i < rank ? 'vault-pip is-filled' : 'vault-pip'));
- const rankLine = el('p', 'vault-meta');
- rankLine.append(pips, document.createTextNode(`Veteran rank ${rank} · ${hero.clears} ${hero.clears === 1 ? 'clear' : 'clears'}`));
- copy.append(
- title,
- el('p', 'vault-kit', kit),
- icons,
- rankLine,
- el('p', 'vault-meta', `First clear: score ${hero.proof_score}, ${hero.proof_hp_left} HP left, ${hero.proof_actions} moves`)
- );
- const go = el('button', 'btn btn-primary', 'Descend as a veteran');
- go.type = 'button';
- go.addEventListener('click', () => view.onVeteran(hero.id));
- item.append(portrait, copy, go);
- return item;
-}
-
-function metric(value: string, label: string): HTMLElement {
- const node = el('div', 'metric');
- node.append(el('span', 'metric-value', value));
- node.append(el('span', 'metric-label', label));
- return node;
-}
-
-function metricGrid(tiles: HTMLElement[]): HTMLElement {
- const grid = el('div', 'metric-grid');
- for (const tile of tiles) grid.append(tile);
- return grid;
-}
-
-function statsCard(view: ChromeView): HTMLElement {
- const card = el('div', 'panel panel-scroll panel-wide');
- card.append(el('h2', 'panel-title', 'Your record'));
- card.append(el('p', 'panel-sub', 'This browser keeps the detail. Stellar keeps the mark anyone can read.'));
-
- const local = el('section', 'metric-group');
- local.append(el('h3', 'metric-heading', 'This browser'));
- const progress = view.progress;
- if (progress.runsPlayed === 0) {
- local.append(el('p', 'panel-body', 'No finished run in this browser yet.'));
- } else {
- const outcomes = progress.extractions + progress.deaths;
- const delvers = progress.lanternRuns + progress.cinderRuns;
- const partial = outcomes < progress.runsPlayed || delvers < progress.runsPlayed;
- const hasNewDetail = outcomes + delvers > 0;
- local.append(
- metricGrid(
- hasNewDetail
- ? [
- metric(String(progress.runsPlayed), 'Runs'),
- metric(String(progress.extractions), 'Extracted'),
- metric(String(progress.deaths), 'Lost'),
- metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'),
- metric(String(progress.bestRelics), 'Best relics'),
- metric(String(progress.relicsKept), 'Relics banked'),
- metric(String(progress.bestScore), 'Best score'),
- metric(String(progress.lanternRuns), 'Lantern'),
- metric(String(progress.cinderRuns), 'Cinder'),
- ]
- : [
- metric(String(progress.runsPlayed), 'Runs'),
- metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'),
- metric(String(progress.bestRelics), 'Best relics'),
- ]
- )
- );
- if (partial) {
- local.append(
- el(
- 'p',
- 'panel-sub',
- 'Wins, losses, score, relics banked, and delver counts include only runs finished after this page shipped. Earlier runs still count toward the total, best floor, and best relics.'
- )
- );
- }
- }
- card.append(local);
-
- const chain = el('section', 'metric-group');
- chain.append(el('h3', 'metric-heading', 'Stellar testnet'));
- if (view.playerAddress) {
- chain.append(el('p', 'panel-sub', `You ${shortenAddress(view.playerAddress)}`));
- }
- if (view.chainRead === 'loading') {
- chain.append(el('p', 'panel-body', 'Reading the Stellar record...'));
- } else if (view.chainRead === 'unavailable') {
- chain.append(el('p', 'panel-body', 'Stellar could not be reached from this browser.'));
- } else if (!view.onchain) {
- chain.append(el('p', 'panel-body', 'No run recorded for you on Stellar yet.'));
- } else {
- chain.append(el('p', 'panel-sub', 'Every number here was derived by the contract replaying your runs.'));
- chain.append(
- metricGrid([
- metric(String(view.onchain.bestScore), 'Best score'),
- metric(`${view.onchain.bestFloor} / ${MAX_FLOOR}`, 'Best floor'),
- metric(String(view.onchain.runs), 'Runs'),
- metric(String(view.onchain.extractions), 'Extracted'),
- metric(String(view.onchain.deaths), 'Lost'),
- metric(String(view.onchain.forfeits), 'Abandoned'),
- metric(String(view.onchain.relicsBanked), 'Relics banked'),
- ])
- );
- }
- if (view.relics) {
- chain.append(
- metricGrid([
- metric(String(view.relics.common), 'Common relics'),
- metric(String(view.relics.rare), 'Rare relics'),
- metric(String(view.relics.core), 'Core relics'),
- ])
- );
- }
- card.append(chain);
-
- const sound = el('section', 'metric-group');
- sound.append(el('h3', 'metric-heading', 'Sound'));
- sound.append(volumeSlider('Effects', getFxVolume(), setFxVolume));
- sound.append(volumeSlider('Ambience', getAmbienceVolume(), setAmbienceVolume));
- card.append(sound);
-
- const actions = el('div', 'result-actions');
- actions.append(button('Replay hints', 'replayHints', 'btn'), button('Back', 'cancel', 'btn'));
- card.append(actions);
- return card;
-}
-
-function volumeSlider(label: string, value: number, onChange: (value: number) => void): HTMLElement {
- const row = el('label', 'volume-row');
- row.append(el('span', 'volume-label', label));
- const input = el('input', 'volume-input');
- input.type = 'range';
- input.min = '0';
- input.max = '1';
- input.step = '0.05';
- input.value = String(value);
- input.addEventListener('input', () => onChange(Number(input.value)));
- row.append(input);
- return row;
-}
-
-function combatCard(run: RunState): HTMLElement {
- const enemy = run.activeEnemyIndex !== null ? run.enemies[run.activeEnemyIndex] : undefined;
- const name = enemy ? ENEMIES[enemy.kind].name : 'Something';
- const card = el('div', 'prompt prompt-danger');
- card.append(el('p', 'prompt-title', `${name} blocks the way.`));
- if (enemy) card.append(el('p', 'prompt-sub', `${enemy.hp} / ${enemy.maxHp} HP`));
- const actions = el('div', 'prompt-actions');
- actions.append(button('Attack', 'confirm', 'btn btn-primary'));
- actions.append(button('Retreat', 'cancel', 'btn'));
- card.append(actions);
- return card;
-}
-
-const RELIC_KIND_ORDER: readonly RelicKind[] = ['core', 'rare', 'common'];
-
-function choiceCard(run: RunState, onDescendHover: (hovering: boolean) => void): HTMLElement {
- const card = el('div', 'prompt prompt-safe');
- card.append(el('p', 'prompt-title', `Floor ${run.floorNumber} is clear.`));
-
- const stake = el('div', 'choice-stake');
-
- const hpRow = el('div', 'choice-hp');
- const track = el('span', 'hp-track');
- const fill = el('span', 'hp-fill');
- const ratio = run.player.maxHp > 0 ? Math.max(0, Math.min(1, run.player.hp / run.player.maxHp)) : 0;
- fill.style.width = `${(ratio * 100).toFixed(1)}%`;
- track.append(fill);
- hpRow.append(track, el('span', 'choice-hp-text', `${run.player.hp} / ${run.player.maxHp} HP`));
- stake.append(hpRow);
-
- const held = RELIC_KIND_ORDER.filter((kind) => run.relicsByKind[kind] > 0);
- if (held.length > 0) {
- const relicRow = el('div', 'choice-relics');
- for (const kind of held) {
- const chip = el('span', 'choice-relic-chip');
- const icon = el('img', 'choice-relic-icon');
- icon.src = `/art/relic-${kind}.webp`;
- icon.alt = '';
- chip.append(icon, el('span', 'sr-only', `${kind} relics `), el('span', '', `×${run.relicsByKind[kind]}`));
- relicRow.append(chip);
- }
- stake.append(relicRow);
- }
-
- stake.append(el('span', 'choice-score', `Score ${run.scoreThisRun}`));
- card.append(stake);
-
- const nextLayout = buildFloor(run.floorNumber + 1);
- const guardians = [...new Set(nextLayout.enemies.map((enemy) => ENEMIES[enemy.kind].name))];
- if (guardians.length > 0) {
- card.append(el('p', 'prompt-sub', `Ahead: ${guardians.join(', ')}.`));
- }
- card.append(el('p', 'prompt-sub', 'Descending keeps your HP and raises the risk. Extracting banks what you hold.'));
-
- const actions = el('div', 'prompt-actions');
- const descend = button('Descend', 'confirm', 'btn btn-primary');
- descend.addEventListener('pointerenter', () => onDescendHover(true));
- descend.addEventListener('pointerleave', () => onDescendHover(false));
- actions.append(descend, button('Extract', 'cancel', 'btn'));
- card.append(actions);
- return card;
-}
-
function resultSummary(view: ChromeView): ResultSummary {
const run = view.run as RunState;
const won = run.resultKind === 'extracted';
@@ -616,7 +196,7 @@ function resultSummary(view: ChromeView): ResultSummary {
score: won ? run.scoreThisRun : 0,
hpLeft: run.player.hp,
newBest: run.newBest,
- bestLine: `Best floor ${view.progress.bestFloor} / ${MAX_FLOOR} · Best relics ${view.progress.bestRelics}`,
+ bestLine: view.practice ? '' : `Best floor ${view.progress.bestFloor} / ${MAX_FLOOR} · Best relics ${view.progress.bestRelics}`,
delver: {
name: run.look.name,
art: HEROES[heroId].veteranArt,
@@ -628,6 +208,7 @@ function resultSummary(view: ChromeView): ResultSummary {
function verificationOf(view: ChromeView): Verification {
const url = TX_HASH.test(view.chainTx) ? `${STELLAR_CONFIG.explorerUrl}/tx/${view.chainTx}` : null;
+ const outcome = delverOutcome(view.chainHeroId, view.runWasVeteran, view.chainClears);
return {
state: view.practice ? 'practice' : view.verifyState,
message: view.practice
@@ -635,8 +216,15 @@ function verificationOf(view: ChromeView): Verification {
: view.chainStatus,
rank: view.chainRank,
txUrl: url,
- heroEarned: view.chainHeroId !== null,
+ heroEarned: outcome.kind === 'earned',
+ delverGrew: outcome.kind === 'grew' ? { clears: outcome.clears } : null,
canRetry: view.canRetry,
+ recordNudge: shouldNudgeRecord({
+ practice: view.practice,
+ verified: view.verifyState === 'verified',
+ won: view.run?.resultKind === 'extracted',
+ verifiedRuns: view.onchain?.runs ?? null,
+ }),
};
}
@@ -654,8 +242,8 @@ export function syncChrome(view: ChromeView): void {
if (playing && view.run) {
const floorName = ['', 'Reliquary', 'Drowned chapel', 'Obsidian crypt'][view.run.floorNumber] ?? '';
- const practice = view.practice ? ' · Practice' : '';
- setText('floor-label', `Floor ${view.run.floorNumber} / ${MAX_FLOOR} ${floorName}${practice}`);
+ // A practice run is already labelled in the run status, so the title bar stays short.
+ setText('floor-label', `Floor ${view.run.floorNumber} / ${MAX_FLOOR} ${floorName}`);
setText('hud-name', view.run.look.name);
paintHp(view.run.player.hp, view.run.player.maxHp);
setBumpText('hud-relics', `Relics ${view.displayRelics}`);
diff --git a/client/src/render/chromeTypes.ts b/client/src/render/chromeTypes.ts
new file mode 100644
index 0000000..9161549
--- /dev/null
+++ b/client/src/render/chromeTypes.ts
@@ -0,0 +1,84 @@
+import type { Draft } from '../game/draft';
+import type { Progress } from '../game/progress';
+import type { RunState } from '../game/state';
+import type { Hero } from '../stellar/contract';
+import type { RelicBalances } from '../stellar/relics';
+import type { CreatorHooks } from './creator';
+import type { DescentOptions, DescentStage } from './descent';
+import type { VerifyState } from './result';
+
+export interface LeaderboardRow {
+ address: string;
+ name: string;
+ emblem: number;
+ score: number;
+ floor: number;
+ hpLeft: number;
+ actions: number;
+}
+
+export type ChainRead = 'loading' | 'ready' | 'unavailable';
+
+export interface OnchainSnapshot {
+ bestFloor: number;
+ bestScore: number;
+ runs: number;
+ extractions: number;
+ deaths: number;
+ forfeits: number;
+ relicsBanked: number;
+}
+
+export type BoardTab = 'fresh' | 'veteran';
+
+export interface ChromeView {
+ showCreator: boolean;
+ showVault: boolean;
+ draft: Draft;
+ creatorHooks: CreatorHooks;
+ vaultHeroes: Hero[] | null;
+ vaultLoading: boolean;
+ onVeteran: (id: number) => void;
+ boardTab: BoardTab;
+ chainHeroId: number | null;
+ /** The veteran's clear count after a verified clear, when known. */
+ chainClears: number | null;
+ runWasVeteran: boolean;
+ canRetry: boolean;
+ verifyState: VerifyState;
+ runId: number;
+ showLeaderboard: boolean;
+ showStats: boolean;
+ leaderboardLoading: boolean;
+ leaderboardRows: LeaderboardRow[] | null;
+ leaderboardFailed: boolean;
+ recoveryNote: string;
+ restoreStep: 'closed' | 'enter' | 'confirm';
+ restoreError: string;
+ restoreTarget: string;
+ displayRelics: number;
+ justVerified: boolean;
+ playerAddress: string;
+ run: RunState | null;
+ progress: Progress;
+ onchain: OnchainSnapshot | null;
+ relics: RelicBalances | null;
+ chainRead: ChainRead;
+ chainStatus: string;
+ chainTx: string;
+ chainRank: number | null;
+ starting: boolean;
+ startStage: DescentStage;
+ descent: DescentOptions | null;
+ practice: boolean;
+ onDescendHover: (hovering: boolean) => void;
+ /** Fired once, synchronously with the seal's strike animation, when a run is verified. */
+ onSeal: () => void;
+}
+
+/** The slice of the view each card reads, so a card can be mounted on its own for previews and checks. */
+export type VaultView = Pick;
+export type LeaderboardView = Pick<
+ ChromeView,
+ 'boardTab' | 'leaderboardLoading' | 'leaderboardRows' | 'leaderboardFailed' | 'playerAddress' | 'justVerified'
+>;
diff --git a/client/src/render/creator.ts b/client/src/render/creator.ts
index 59a3f60..1a28d22 100644
--- a/client/src/render/creator.ts
+++ b/client/src/render/creator.ts
@@ -3,6 +3,7 @@ import { CHARMS, WEAPONS, type Item } from '../game/items';
import { isValidName, NAME_MAX, NAME_MIN, statsFor, EMBLEM_COUNT } from '../game/loadout';
import { loadoutOf, randomName, saveDraft, type Draft } from '../game/draft';
import { clearPercent, difficultyOf, DIFFICULTY_LABEL, isRecommended, RECOMMENDED_KIT, type Difficulty } from '../game/kitDifficulty';
+import { isTouchFirst } from './dom';
import { cloakCount, cloakFilter, cloakName, cloakSwatch, EMBLEM_NAMES, emblemIcon } from './emblems';
export interface CreatorHooks {
@@ -44,11 +45,7 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo
root.noValidate = true;
root.append(el('h2', 'panel-title', 'Forge your delver'));
root.append(
- el(
- 'p',
- 'panel-sub',
- 'Clear all three floors and this delver is yours to keep, as a token on Stellar. Fall, and it is gone.'
- )
+ el('p', 'panel-sub', 'Clear all three floors and this delver is yours to keep, as a token on Stellar. Fall, and it is gone.')
);
// Preview
@@ -166,7 +163,11 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo
baseGroup.append(baseList);
root.append(baseGroup);
- function itemGroup(label: string, items: readonly Item[], pick: (id: number) => void): { group: HTMLElement; buttons: HTMLButtonElement[] } {
+ function itemGroup(
+ label: string,
+ items: readonly Item[],
+ pick: (id: number) => void
+ ): { group: HTMLElement; buttons: HTMLButtonElement[] } {
const group = el('div', 'field');
group.append(el('span', 'field-label', label));
const list = el('div', 'pick-grid');
@@ -202,7 +203,7 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo
});
root.append(weapons.group, charms.group);
- const actions = el('div', 'title-actions');
+ const actions = el('div', 'title-actions sticky-actions');
const begin = el('button', 'btn btn-primary', 'Begin descent');
begin.type = 'submit';
const back = el('button', 'btn', 'Back');
@@ -220,9 +221,7 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo
function refresh(): void {
const name = draft.look.name;
const valid = isValidName(name);
- nameHint.textContent = valid
- ? `${name.length} / ${NAME_MAX}`
- : `Use ${NAME_MIN} to ${NAME_MAX} letters, numbers and single spaces.`;
+ nameHint.textContent = valid ? `${name.length} / ${NAME_MAX}` : `Use ${NAME_MIN} to ${NAME_MAX} letters, numbers and single spaces.`;
nameHint.classList.toggle('is-error', !valid);
nameInput.setAttribute('aria-invalid', valid ? 'false' : 'true');
begin.disabled = !valid;
@@ -286,5 +285,6 @@ export function mountCreator(host: HTMLElement, initial: Draft, hooks: CreatorHo
host.append(root);
refresh();
- nameInput.focus();
+ // A finger cannot use the field before tapping it, and focusing it here would raise the keyboard over the form.
+ if (!isTouchFirst()) nameInput.focus();
}
diff --git a/client/src/render/descent.ts b/client/src/render/descent.ts
index 422a4e6..1ebb950 100644
--- a/client/src/render/descent.ts
+++ b/client/src/render/descent.ts
@@ -6,6 +6,7 @@
* dark. Restraint is the point: nothing here tries to look like a loading bar.
*/
import { emblemIcon } from './emblems';
+import { waitState } from './descentWait';
import { prefersReducedMotion } from './preferences';
export interface DescentOptions {
@@ -17,6 +18,8 @@ export interface DescentOptions {
emblem: number;
/** One line describing the kit, such as "Cinder · Reaver Hook · Stone Heart". */
kit: string;
+ /** Starts a practice run right away. When absent the scene never offers the button. */
+ onSkip?: () => void;
}
export type DescentStage = 'account' | 'opening' | 'ready' | 'offline';
@@ -40,6 +43,7 @@ const TIPS: readonly string[] = [
];
const TIP_MS = 3600;
+const WAIT_POLL_MS = 500;
const EMBER_COUNT = 34;
let setStageRef: ((stage: DescentStage) => void) | null = null;
@@ -126,7 +130,16 @@ export function mountDescent(host: HTMLElement, options: DescentOptions): void {
const status = el('p', 'descent-status', STAGE_TEXT.account);
status.setAttribute('role', 'status');
const tip = el('p', 'descent-tip');
- progress.append(steps, status, tip);
+ const slowNote = el('p', 'descent-slow', 'Stellar is taking a little longer than usual. Your run will open as soon as it can.');
+ slowNote.hidden = true;
+ const skip = el('button', 'btn descent-skip', 'Play a practice run now');
+ skip.type = 'button';
+ skip.hidden = true;
+ skip.addEventListener('click', () => {
+ skip.disabled = true;
+ options.onSkip?.();
+ });
+ progress.append(steps, status, slowNote, skip, tip);
root.append(art, light, dust, grain, shade, dossier, progress);
host.append(root);
@@ -152,7 +165,19 @@ export function mountDescent(host: HTMLElement, options: DescentOptions): void {
}
paintSteps(0, false);
+ // After a few calm seconds the scene says so, then offers a way out that never blocks the chain queue.
+ let stageNow: DescentStage = 'account';
+ const shownAt = performance.now();
+ function paintWait(): void {
+ const wait = waitState(performance.now() - shownAt, stageNow);
+ slowNote.hidden = !wait.slow;
+ skip.hidden = !options.onSkip || !wait.canSkip;
+ }
+ const waitTimer = window.setInterval(paintWait, WAIT_POLL_MS);
+
setStageRef = (stage) => {
+ stageNow = stage;
+ paintWait();
status.textContent = STAGE_TEXT[stage];
if (stage === 'account') paintSteps(0, false);
if (stage === 'opening') paintSteps(1, false);
@@ -219,6 +244,7 @@ export function mountDescent(host: HTMLElement, options: DescentOptions): void {
if (!root.isConnected) {
running = false;
window.clearInterval(tipTimer);
+ window.clearInterval(waitTimer);
if (setStageRef) setStageRef = null;
observer.disconnect();
}
diff --git a/client/src/render/descentWait.ts b/client/src/render/descentWait.ts
new file mode 100644
index 0000000..7ba9e7f
--- /dev/null
+++ b/client/src/render/descentWait.ts
@@ -0,0 +1,18 @@
+import { SKIP_OFFER_AFTER_MS, SLOW_NOTE_AFTER_MS } from '../constants';
+import type { DescentStage } from './descent';
+
+export interface WaitState {
+ /** Say, calmly, that Stellar is taking a little longer. */
+ slow: boolean;
+ /** Offer the "Play a practice run now" button. */
+ canSkip: boolean;
+}
+
+/** What the descent scene shows after waiting `elapsedMs` in `stage`. Only the real waiting stages ever show either. */
+export function waitState(elapsedMs: number, stage: DescentStage): WaitState {
+ const waiting = stage === 'account' || stage === 'opening';
+ return {
+ slow: waiting && elapsedMs >= SLOW_NOTE_AFTER_MS,
+ canSkip: waiting && elapsedMs >= SKIP_OFFER_AFTER_MS,
+ };
+}
diff --git a/client/src/render/dom.ts b/client/src/render/dom.ts
new file mode 100644
index 0000000..e2ec4aa
--- /dev/null
+++ b/client/src/render/dom.ts
@@ -0,0 +1,42 @@
+/** Small DOM builders shared by the chrome's cards. */
+
+export function el(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+export function button(label: string, action: string, className = 'btn'): HTMLButtonElement {
+ const node = el('button', className, label);
+ node.type = 'button';
+ node.dataset.action = action;
+ return node;
+}
+
+export function shortenAddress(address: string): string {
+ if (address.length < 10) return address;
+ return `${address.slice(0, 4)}...${address.slice(-4)}`;
+}
+
+export function setText(id: string, value: string): void {
+ const node = document.getElementById(id);
+ if (node && node.textContent !== value) node.textContent = value;
+}
+
+/** Sets text and, if it changed, replays a small pop so gains are felt. */
+export function setBumpText(id: string, value: string): void {
+ const node = document.getElementById(id);
+ if (!node || node.textContent === value) return;
+ const first = node.textContent === '';
+ node.textContent = value;
+ if (first) return;
+ node.classList.remove('bump');
+ void node.offsetWidth;
+ node.classList.add('bump');
+}
+
+/** True when the main pointer is a finger. Focusing a field there raises the keyboard and pulls the page, so nothing is auto focused. */
+export function isTouchFirst(): boolean {
+ return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
+}
diff --git a/client/src/render/effects.ts b/client/src/render/effects.ts
index 0233bb2..dd1664b 100644
--- a/client/src/render/effects.ts
+++ b/client/src/render/effects.ts
@@ -44,10 +44,7 @@ export function getShakeOffset(): [number, number] {
return [Math.sin(elapsed * 0.9 + 1.3) * mag, Math.cos(elapsed * 1.1) * mag];
}
-export function drawPopups(
- ctx: CanvasRenderingContext2D,
- toScreen: (x: number, y: number) => { cx: number; cy: number }
-): void {
+export function drawPopups(ctx: CanvasRenderingContext2D, toScreen: (x: number, y: number) => { cx: number; cy: number }): void {
const now = clockNow();
popups = popups.filter((p) => now - p.start < POPUP_LIFETIME_MS);
for (const popup of popups) {
@@ -125,10 +122,7 @@ export function spawnBurst(x: number, y: number, kind: BurstKind): void {
}
}
-export function drawParticles(
- ctx: CanvasRenderingContext2D,
- toScreen: (x: number, y: number) => { cx: number; cy: number }
-): void {
+export function drawParticles(ctx: CanvasRenderingContext2D, toScreen: (x: number, y: number) => { cx: number; cy: number }): void {
const now = clockNow();
particles = particles.filter((p) => now - p.start < p.ttl);
for (const p of particles) {
@@ -156,7 +150,7 @@ export function drawLowHealth(ctx: CanvasRenderingContext2D, ratio: number): voi
const h = ctx.canvas.height;
const urgency = 1 - ratio / 0.34;
// A steady tint instead of a heartbeat when the player asks for reduced motion.
- const beat = prefersReducedMotion() ? 1 : Math.pow(Math.max(0, Math.sin(clockNow() / 1000 * (3.2 + urgency * 2))), 6);
+ const beat = prefersReducedMotion() ? 1 : Math.max(0, Math.sin((clockNow() / 1000) * (3.2 + urgency * 2))) ** 6;
const alpha = (0.1 + urgency * 0.28) * (0.45 + 0.55 * beat);
const g = ctx.createRadialGradient(w / 2, h / 2, h * 0.32, w / 2, h / 2, h * 0.8);
g.addColorStop(0, 'rgba(255, 60, 50, 0)');
@@ -196,7 +190,7 @@ export function drawRelicTravel(ctx: CanvasRenderingContext2D): void {
arrived.push(t.kind);
return false;
}
- const eased = 1 - Math.pow(1 - p, 3);
+ const eased = 1 - (1 - p) ** 3;
const x = t.x0 + (t.x1 - t.x0) * eased;
const lift = Math.sin(p * Math.PI) * -26;
const y = t.y0 + (t.y1 - t.y0) * eased + lift;
diff --git a/client/src/render/hints.ts b/client/src/render/hints.ts
index 082fd9e..e7f7e66 100644
--- a/client/src/render/hints.ts
+++ b/client/src/render/hints.ts
@@ -4,6 +4,8 @@
* of the board, never a modal, that steps out of the way the moment the
* player answers it, so it never blocks input.
*/
+import { readJson, writeJson } from '../storage';
+
export type HintId = 'move' | 'combat' | 'choice';
const STORAGE_KEY = 'deepmark:hints:v1';
@@ -20,21 +22,14 @@ interface HintRecord {
}
function load(): HintRecord {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (raw) return JSON.parse(raw) as HintRecord;
- } catch {
- // Private mode or corrupt data: the hint just shows again, which is harmless.
- }
+ const stored = readJson(STORAGE_KEY);
+ // Missing or corrupt data just shows the hint again, which is harmless.
+ if (stored && typeof stored === 'object' && 'seen' in stored) return stored as HintRecord;
return { seen: {} };
}
function save(): void {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
- } catch {
- // Nothing persists, but the hint still behaves correctly for this tab.
- }
+ writeJson(STORAGE_KEY, record);
}
let record = load();
diff --git a/client/src/render/leaderboardCard.ts b/client/src/render/leaderboardCard.ts
new file mode 100644
index 0000000..93c04a6
--- /dev/null
+++ b/client/src/render/leaderboardCard.ts
@@ -0,0 +1,68 @@
+import { MAX_FLOOR } from '../game/floor';
+import { button, el, shortenAddress } from './dom';
+import { emblemIcon } from './emblems';
+import { loadingBlock } from './loader';
+import type { LeaderboardView } from './chromeTypes';
+
+/** Rows enter one after another so the table reads top down. */
+const ROW_STAGGER_MS = 60;
+/** The rank number of a freshly verified row lands a beat after its row. */
+const FRESH_RANK_DELAY_MS = 180;
+
+export function leaderboardCard(view: LeaderboardView): HTMLElement {
+ const card = el('div', 'panel panel-scroll');
+ card.append(el('h2', 'panel-title', 'Leaderboard'));
+ card.append(el('p', 'panel-sub', 'Best verified runs. The contract replays every run before it is ranked.'));
+
+ const tabs = el('div', 'tab-row');
+ const fresh = button('Fresh delvers', 'tabFresh', view.boardTab === 'fresh' ? 'btn btn-primary' : 'btn');
+ const veteran = button('Veterans', 'tabVeteran', view.boardTab === 'veteran' ? 'btn btn-primary' : 'btn');
+ fresh.setAttribute('aria-pressed', view.boardTab === 'fresh' ? 'true' : 'false');
+ veteran.setAttribute('aria-pressed', view.boardTab === 'veteran' ? 'true' : 'false');
+ tabs.append(fresh, veteran);
+ card.append(tabs);
+
+ if (view.leaderboardLoading) {
+ card.append(loadingBlock('Loading records'));
+ } else if (view.leaderboardFailed) {
+ card.append(el('p', 'panel-body', 'The leaderboard could not be read right now. Your connection or Stellar may be having a moment.'));
+ card.append(button('Try again', 'retryBoard', 'btn btn-primary'));
+ } else if (!view.leaderboardRows || view.leaderboardRows.length === 0) {
+ card.append(
+ el('p', 'panel-body', view.boardTab === 'veteran' ? 'No veteran has descended yet.' : 'No verified runs yet. Be the first.')
+ );
+ } else {
+ const table = el('ol', 'board');
+ view.leaderboardRows.forEach((row, index) => {
+ const mine = row.address === view.playerAddress;
+ const fresh = mine && view.justVerified;
+ const item = el('li', mine ? 'board-row is-you' : 'board-row');
+ if (fresh) item.classList.add('is-fresh');
+ item.style.animationDelay = `${index * ROW_STAGGER_MS}ms`;
+ const rank = el('span', 'board-rank', `${index + 1}`);
+ if (fresh) rank.style.animationDelay = `${index * ROW_STAGGER_MS + FRESH_RANK_DELAY_MS}ms`;
+ item.append(rank);
+ const who = el('span', 'board-who');
+ who.append(emblemIcon(row.emblem, 'emblem emblem-small'));
+ who.append(el('span', 'board-name', mine ? `${row.name} (you)` : row.name));
+ who.append(el('span', 'board-addr', shortenAddress(row.address)));
+ item.append(who);
+ item.append(el('span', 'board-stat', `${row.score}`));
+ item.append(el('span', 'board-stat', `${row.floor}/${MAX_FLOOR}`));
+ item.append(el('span', 'board-stat', `${row.hpLeft}`));
+ item.append(el('span', 'board-stat', `${row.actions}`));
+ table.append(item);
+ });
+ const head = el('div', 'board-head');
+ head.append(el('span', '', '#'));
+ head.append(el('span', '', 'Delver'));
+ head.append(el('span', '', 'Score'));
+ head.append(el('span', '', 'Floor'));
+ head.append(el('span', '', 'HP'));
+ head.append(el('span', '', 'Moves'));
+ card.append(head, table);
+ }
+
+ card.append(button('Back', 'cancel', 'btn'));
+ return card;
+}
diff --git a/client/src/render/loader.ts b/client/src/render/loader.ts
new file mode 100644
index 0000000..e8e273a
--- /dev/null
+++ b/client/src/render/loader.ts
@@ -0,0 +1,37 @@
+import '../loader.css';
+import { sigilIcon } from './sigil';
+
+const DEFAULT_ROWS = 3;
+
+/**
+ * The shared waiting state for anything that reads the chain: the Deepmark
+ * sigil breathing above a few skeleton rows. It is announced once as a status
+ * and marked busy, and the decorative parts are hidden from assistive tech.
+ */
+export function loadingBlock(message: string, rows = DEFAULT_ROWS): HTMLElement {
+ const block = document.createElement('div');
+ block.className = 'loader';
+ block.setAttribute('role', 'status');
+ block.setAttribute('aria-busy', 'true');
+
+ const mark = document.createElement('span');
+ mark.className = 'loader-mark';
+ mark.append(sigilIcon());
+ mark.setAttribute('aria-hidden', 'true');
+
+ const text = document.createElement('p');
+ text.className = 'loader-text';
+ text.textContent = message;
+
+ const lines = document.createElement('div');
+ lines.className = 'loader-rows';
+ lines.setAttribute('aria-hidden', 'true');
+ for (let i = 0; i < rows; i += 1) {
+ const line = document.createElement('span');
+ line.className = 'loader-row';
+ lines.append(line);
+ }
+
+ block.append(mark, text, lines);
+ return block;
+}
diff --git a/client/src/render/motion.ts b/client/src/render/motion.ts
index cc6822a..33023d9 100644
--- a/client/src/render/motion.ts
+++ b/client/src/render/motion.ts
@@ -38,7 +38,11 @@ export function playAttack(id: string, x: number, y: number): void {
});
}
-export function sampleActor(id: string, x: number, y: number): {
+export function sampleActor(
+ id: string,
+ x: number,
+ y: number
+): {
x: number;
y: number;
clip: Clip;
diff --git a/client/src/render/promptCards.ts b/client/src/render/promptCards.ts
new file mode 100644
index 0000000..8dc41ed
--- /dev/null
+++ b/client/src/render/promptCards.ts
@@ -0,0 +1,67 @@
+import { ENEMIES } from '../game/enemies';
+import { buildFloor, type RelicKind } from '../game/floor';
+import type { RunState } from '../game/state';
+import { button, el } from './dom';
+
+export function combatCard(run: RunState): HTMLElement {
+ const enemy = run.activeEnemyIndex !== null ? run.enemies[run.activeEnemyIndex] : undefined;
+ const name = enemy ? ENEMIES[enemy.kind].name : 'Something';
+ const card = el('div', 'prompt prompt-danger');
+ card.append(el('p', 'prompt-title', `${name} blocks the way.`));
+ if (enemy) card.append(el('p', 'prompt-sub', `${enemy.hp} / ${enemy.maxHp} HP`));
+ const actions = el('div', 'prompt-actions');
+ actions.append(button('Attack', 'confirm', 'btn btn-primary'));
+ actions.append(button('Retreat', 'cancel', 'btn'));
+ card.append(actions);
+ return card;
+}
+
+const RELIC_KIND_ORDER: readonly RelicKind[] = ['core', 'rare', 'common'];
+
+export function choiceCard(run: RunState, onDescendHover: (hovering: boolean) => void): HTMLElement {
+ const card = el('div', 'prompt prompt-safe');
+ card.append(el('p', 'prompt-title', `Floor ${run.floorNumber} is clear.`));
+
+ const stake = el('div', 'choice-stake');
+
+ const hpRow = el('div', 'choice-hp');
+ const track = el('span', 'hp-track');
+ const fill = el('span', 'hp-fill');
+ const ratio = run.player.maxHp > 0 ? Math.max(0, Math.min(1, run.player.hp / run.player.maxHp)) : 0;
+ fill.style.width = `${(ratio * 100).toFixed(1)}%`;
+ track.append(fill);
+ hpRow.append(track, el('span', 'choice-hp-text', `${run.player.hp} / ${run.player.maxHp} HP`));
+ stake.append(hpRow);
+
+ const held = RELIC_KIND_ORDER.filter((kind) => run.relicsByKind[kind] > 0);
+ if (held.length > 0) {
+ const relicRow = el('div', 'choice-relics');
+ for (const kind of held) {
+ const chip = el('span', 'choice-relic-chip');
+ const icon = el('img', 'choice-relic-icon');
+ icon.src = `/art/relic-${kind}.webp`;
+ icon.alt = '';
+ chip.append(icon, el('span', 'sr-only', `${kind} relics `), el('span', '', `×${run.relicsByKind[kind]}`));
+ relicRow.append(chip);
+ }
+ stake.append(relicRow);
+ }
+
+ stake.append(el('span', 'choice-score', `Score ${run.scoreThisRun}`));
+ card.append(stake);
+
+ const nextLayout = buildFloor(run.floorNumber + 1);
+ const guardians = [...new Set(nextLayout.enemies.map((enemy) => ENEMIES[enemy.kind].name))];
+ if (guardians.length > 0) {
+ card.append(el('p', 'prompt-sub', `Ahead: ${guardians.join(', ')}.`));
+ }
+ card.append(el('p', 'prompt-sub', 'Descending keeps your HP and raises the risk. Extracting banks what you hold.'));
+
+ const actions = el('div', 'prompt-actions');
+ const descend = button('Descend', 'confirm', 'btn btn-primary');
+ descend.addEventListener('pointerenter', () => onDescendHover(true));
+ descend.addEventListener('pointerleave', () => onDescendHover(false));
+ actions.append(descend, button('Extract', 'cancel', 'btn'));
+ card.append(actions);
+ return card;
+}
diff --git a/client/src/render/result.ts b/client/src/render/result.ts
index 46755dc..c8426b0 100644
--- a/client/src/render/result.ts
+++ b/client/src/render/result.ts
@@ -8,8 +8,10 @@
* Like the creator, this component owns its DOM. The overlay renderer mounts it
* once per run and only calls `updateVerification` afterwards.
*/
+import { DELVER_EARNED_KICKER, delverGrewKicker } from '../game/delverOutcome';
import { emblemIcon } from './emblems';
import { prefersReducedMotion } from './preferences';
+import { sigilIcon } from './sigil';
export interface ResultSummary {
won: boolean;
@@ -31,8 +33,13 @@ export interface Verification {
message: string;
rank: number | null;
txUrl: string | null;
+ /** A fresh delver was minted by this run (its first full clear). */
heroEarned: boolean;
+ /** A veteran cleared again: no new delver, but its clear count rose. Null clears means the count is not known yet. */
+ delverGrew?: { clears: number | null } | null;
canRetry: boolean;
+ /** A fresh account's first verified win: gently point at Your record so the delver is never lost. */
+ recordNudge?: boolean;
}
const STORY: Record<'pending' | 'waiting', string> = {
@@ -58,7 +65,7 @@ function countUp(node: HTMLElement, to: number, suffix = '', delay = 0): void {
const start = performance.now();
const step = (now: number): void => {
const t = Math.min(1, (now - start) / duration);
- const eased = 1 - Math.pow(1 - t, 3);
+ const eased = 1 - (1 - t) ** 3;
node.textContent = `${Math.round(to * eased)}${suffix}`;
if (t < 1) requestAnimationFrame(step);
};
@@ -75,37 +82,6 @@ function stat(label: string, value: number, suffix: string, delay: number): HTML
return tile;
}
-const SVG_NS = 'http://www.w3.org/2000/svg';
-
-function path(d: string, className: string): SVGPathElement {
- const node = document.createElementNS(SVG_NS, 'path');
- node.setAttribute('d', d);
- node.setAttribute('class', className);
- return node;
-}
-
-/**
- * The Deepmark sigil: the carved triangle-and-eye mark from the stone logo
- * (`/art/logo-lockup.webp`), redrawn as an inline vector so the seal is the
- * game's own mark instead of a generic check-in-a-ring.
- */
-function sigilIcon(): SVGSVGElement {
- const svg = document.createElementNS(SVG_NS, 'svg');
- svg.setAttribute('viewBox', '0 0 64 64');
- svg.setAttribute('class', 'seal-icon');
- svg.setAttribute('aria-hidden', 'true');
- const outer = path('M32 9 L53 51 L11 51 Z', 'seal-outer');
- const inner = path('M23 19 L41 19 L32 31 Z', 'seal-inner');
- const eye = path('M21 41 Q32 34 43 41 Q32 48 21 41 Z', 'seal-eye');
- const pupil = document.createElementNS(SVG_NS, 'circle');
- pupil.setAttribute('cx', '32');
- pupil.setAttribute('cy', '41');
- pupil.setAttribute('r', '2.4');
- pupil.setAttribute('class', 'seal-pupil');
- svg.append(outer, inner, eye, pupil);
- return svg;
-}
-
const STRIKE_KEYFRAMES: Keyframe[] = [
{ opacity: 0, transform: 'scale(1.9) rotate(-14deg) translateY(-6px)' },
{ opacity: 1, transform: 'scale(0.92) rotate(-4deg) translateY(0)', offset: 0.55 },
@@ -161,7 +137,7 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
);
card.append(stats);
if (summary.newBest) card.append(el('p', 'new-best', 'New best'));
- card.append(el('p', 'stats', summary.bestLine));
+ if (summary.bestLine) card.append(el('p', 'stats', summary.bestLine));
// The replay ribbon: one pip per floor, a sweeping light while the contract reads the run.
const ribbon = el('div', 'ribbon is-pending');
@@ -209,7 +185,8 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
crest.append(emblemIcon(summary.delver.emblem, 'emblem'));
medallion.append(portrait, crest);
const heroCopy = el('div', 'hero-copy-block');
- heroCopy.append(el('p', 'hero-kicker', 'A new delver is yours'), el('p', 'hero-earned-name', summary.delver.name));
+ const kicker = el('p', 'hero-kicker', DELVER_EARNED_KICKER);
+ heroCopy.append(kicker, el('p', 'hero-earned-name', summary.delver.name));
const vault = el('button', 'btn btn-primary', 'Open the vault');
vault.type = 'button';
vault.dataset.action = 'vault';
@@ -217,6 +194,14 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
hero.append(medallion, heroCopy);
card.append(hero);
+ const nudge = el('p', 'record-nudge', 'Your delver lives in this browser, so keep your record safe. ');
+ nudge.hidden = true;
+ const nudgeLink = el('button', 'quiet-link', 'Open Your record');
+ nudgeLink.type = 'button';
+ nudgeLink.dataset.action = 'stats';
+ nudge.append(nudgeLink);
+ card.append(nudge);
+
const share = el('div', 'share-actions');
share.hidden = true;
const shareSave = el('button', 'btn', 'Save image');
@@ -231,15 +216,18 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
share.append(shareSave, shareCopy, shareChallenge);
card.append(share);
- const actions = el('div', 'result-actions');
+ const actions = el('div', 'result-actions sticky-actions');
const retry = el('button', 'btn', 'Try again');
retry.type = 'button';
retry.dataset.action = 'retryVerify';
retry.hidden = true;
+ const again = el('button', 'btn', 'Descend again');
+ again.type = 'button';
+ again.dataset.action = 'again';
const cont = el('button', 'btn btn-primary', 'Title screen');
cont.type = 'button';
cont.dataset.action = 'confirm';
- actions.append(retry, cont);
+ actions.append(retry, again, cont);
card.append(actions);
host.append(card);
@@ -250,7 +238,7 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
let last = '';
let sealed = false;
applyVerification = (v) => {
- const key = `${v.state}|${v.message}|${v.rank}|${v.txUrl}|${v.heroEarned}|${v.canRetry}`;
+ const key = `${v.state}|${v.message}|${v.rank}|${v.txUrl}|${v.heroEarned}|${v.delverGrew?.clears ?? 'x'}|${v.delverGrew ? 1 : 0}|${v.canRetry}|${v.recordNudge ? 1 : 0}`;
if (key === last) return;
last = key;
@@ -258,6 +246,7 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
ribbon.classList.toggle('is-failed', v.state === 'failed');
ribbon.classList.toggle('is-practice', v.state === 'practice');
retry.hidden = !v.canRetry;
+ nudge.hidden = !v.recordNudge;
if (v.state === 'pending' || v.state === 'waiting') {
story.textContent = STORY[v.state];
@@ -281,7 +270,8 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
tx.hidden = false;
tx.href = v.txUrl;
}
- if (v.heroEarned) {
+ if (v.heroEarned || v.delverGrew) {
+ kicker.textContent = v.delverGrew ? delverGrewKicker(v.delverGrew.clears) : DELVER_EARNED_KICKER;
hero.hidden = false;
hero.classList.add('is-revealed');
}
diff --git a/client/src/render/sigil.ts b/client/src/render/sigil.ts
new file mode 100644
index 0000000..554adf4
--- /dev/null
+++ b/client/src/render/sigil.ts
@@ -0,0 +1,30 @@
+const SVG_NS = 'http://www.w3.org/2000/svg';
+
+function path(d: string, className: string): SVGPathElement {
+ const node = document.createElementNS(SVG_NS, 'path');
+ node.setAttribute('d', d);
+ node.setAttribute('class', className);
+ return node;
+}
+
+/**
+ * The Deepmark sigil: the carved triangle-and-eye mark from the stone logo
+ * (`/art/logo-lockup.webp`), redrawn as an inline vector so the seal is the
+ * game's own mark instead of a generic check-in-a-ring.
+ */
+export function sigilIcon(): SVGSVGElement {
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', '0 0 64 64');
+ svg.setAttribute('class', 'seal-icon');
+ svg.setAttribute('aria-hidden', 'true');
+ const outer = path('M32 9 L53 51 L11 51 Z', 'seal-outer');
+ const inner = path('M23 19 L41 19 L32 31 Z', 'seal-inner');
+ const eye = path('M21 41 Q32 34 43 41 Q32 48 21 41 Z', 'seal-eye');
+ const pupil = document.createElementNS(SVG_NS, 'circle');
+ pupil.setAttribute('cx', '32');
+ pupil.setAttribute('cy', '41');
+ pupil.setAttribute('r', '2.4');
+ pupil.setAttribute('class', 'seal-pupil');
+ svg.append(outer, inner, eye, pupil);
+ return svg;
+}
diff --git a/client/src/render/statsCard.ts b/client/src/render/statsCard.ts
new file mode 100644
index 0000000..bbd23fa
--- /dev/null
+++ b/client/src/render/statsCard.ts
@@ -0,0 +1,212 @@
+import { MAX_FLOOR } from '../game/floor';
+import { getAmbienceVolume, getFxVolume, setAmbienceVolume, setFxVolume } from './audio';
+import { emitAction } from '../input/keyboard';
+import { RESTORE_FIELD_ID } from '../constants';
+import { button, el, shortenAddress } from './dom';
+import { loadingBlock } from './loader';
+import type { ChromeView } from './chromeTypes';
+
+/** Volume sliders move in 5 percent steps. */
+const VOLUME_STEP = '0.05';
+
+function metric(value: string, label: string): HTMLElement {
+ const node = el('div', 'metric');
+ node.append(el('span', 'metric-value', value));
+ node.append(el('span', 'metric-label', label));
+ return node;
+}
+
+function metricGrid(tiles: HTMLElement[]): HTMLElement {
+ const grid = el('div', 'metric-grid');
+ for (const tile of tiles) grid.append(tile);
+ return grid;
+}
+
+/** Step one of restoring: an in-page field. The pasted text is masked and never echoed back. */
+function restoreForm(view: ChromeView): HTMLElement {
+ const form = el('form', 'field');
+ form.noValidate = true;
+ const label = el('label', 'field-label', 'Paste your recovery code');
+ label.htmlFor = RESTORE_FIELD_ID;
+ const input = el('input', 'input');
+ input.id = RESTORE_FIELD_ID;
+ input.type = 'password';
+ input.autocomplete = 'off';
+ input.spellcheck = false;
+ input.setAttribute('autocapitalize', 'off');
+ if (view.restoreError) {
+ input.setAttribute('aria-invalid', 'true');
+ input.setAttribute('aria-describedby', 'restore-hint');
+ }
+ const hint = el(
+ 'p',
+ view.restoreError ? 'field-hint is-error' : 'field-hint',
+ view.restoreError || 'Nothing changes until you confirm on the next step.'
+ );
+ hint.id = 'restore-hint';
+ hint.setAttribute('role', view.restoreError ? 'alert' : 'note');
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ emitAction('restoreCheck');
+ });
+ const actions = el('div', 'result-actions');
+ const next = button('Continue', 'restoreCheck', 'btn');
+ actions.append(next, button('Cancel', 'restoreCancel', 'btn'));
+ form.append(label, input, hint, actions);
+ setTimeout(() => input.focus({ preventScroll: true }), 0);
+ return form;
+}
+
+/** Step two: replacing the account is explicit, and the current code can be copied first. */
+function restoreConfirm(view: ChromeView): HTMLElement {
+ const box = el('div', 'field');
+ const who = view.playerAddress ? ` (${shortenAddress(view.playerAddress)})` : '';
+ box.append(
+ el(
+ 'p',
+ 'panel-body',
+ `This browser will switch to the account ${view.restoreTarget}. Your current account${who} will be replaced, and you can only get its record and relics back with its own recovery code.`
+ )
+ );
+ if (view.recoveryNote) box.append(el('p', 'field-hint', view.recoveryNote));
+ if (view.restoreError) box.append(el('p', 'field-hint is-error', view.restoreError));
+ const actions = el('div', 'result-actions');
+ actions.append(
+ button('Copy current recovery code first', 'copyRecovery', 'btn'),
+ button('Replace this account', 'restoreConfirm', 'btn'),
+ button('Keep my current account', 'restoreCancel', 'btn')
+ );
+ box.append(actions);
+ return box;
+}
+
+export function statsCard(view: ChromeView): HTMLElement {
+ const card = el('div', 'panel panel-scroll panel-wide');
+ card.append(el('h2', 'panel-title', 'Your record'));
+ card.append(el('p', 'panel-sub', 'This browser keeps the detail. Stellar keeps the mark anyone can read.'));
+
+ const local = el('section', 'metric-group');
+ local.append(el('h3', 'metric-heading', 'This browser'));
+ const progress = view.progress;
+ if (progress.runsPlayed === 0) {
+ local.append(el('p', 'panel-body', 'No finished run in this browser yet.'));
+ } else {
+ const outcomes = progress.extractions + progress.deaths;
+ const delvers = progress.lanternRuns + progress.cinderRuns;
+ const partial = outcomes < progress.runsPlayed || delvers < progress.runsPlayed;
+ const hasNewDetail = outcomes + delvers > 0;
+ local.append(
+ metricGrid(
+ hasNewDetail
+ ? [
+ metric(String(progress.runsPlayed), 'Runs'),
+ metric(String(progress.extractions), 'Extracted'),
+ metric(String(progress.deaths), 'Lost'),
+ metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'),
+ metric(String(progress.bestRelics), 'Best relics'),
+ metric(String(progress.relicsKept), 'Relics banked'),
+ metric(String(progress.bestScore), 'Best score'),
+ metric(String(progress.lanternRuns), 'Lantern'),
+ metric(String(progress.cinderRuns), 'Cinder'),
+ ]
+ : [
+ metric(String(progress.runsPlayed), 'Runs'),
+ metric(`${progress.bestFloor} / ${MAX_FLOOR}`, 'Best floor'),
+ metric(String(progress.bestRelics), 'Best relics'),
+ ]
+ )
+ );
+ if (partial) {
+ local.append(
+ el(
+ 'p',
+ 'panel-sub',
+ 'Wins, losses, score, relics banked, and delver counts include only runs finished after this page shipped. Earlier runs still count toward the total, best floor, and best relics.'
+ )
+ );
+ }
+ }
+ card.append(local);
+
+ const chain = el('section', 'metric-group');
+ chain.append(el('h3', 'metric-heading', 'Stellar testnet'));
+ if (view.playerAddress) {
+ chain.append(el('p', 'panel-sub', `You ${shortenAddress(view.playerAddress)}`));
+ }
+ if (view.chainRead === 'loading') {
+ chain.append(loadingBlock('Reading the Stellar record', 2));
+ } else if (view.chainRead === 'unavailable') {
+ chain.append(el('p', 'panel-body', 'Stellar could not be reached from this browser.'));
+ } else if (!view.onchain) {
+ chain.append(el('p', 'panel-body', 'No run recorded for you on Stellar yet.'));
+ } else {
+ chain.append(el('p', 'panel-sub', 'Every number here was derived by the contract replaying your runs.'));
+ chain.append(
+ metricGrid([
+ metric(String(view.onchain.bestScore), 'Best score'),
+ metric(`${view.onchain.bestFloor} / ${MAX_FLOOR}`, 'Best floor'),
+ metric(String(view.onchain.runs), 'Runs'),
+ metric(String(view.onchain.extractions), 'Extracted'),
+ metric(String(view.onchain.deaths), 'Lost'),
+ metric(String(view.onchain.forfeits), 'Abandoned'),
+ metric(String(view.onchain.relicsBanked), 'Relics banked'),
+ ])
+ );
+ }
+ if (view.relics) {
+ chain.append(
+ metricGrid([
+ metric(String(view.relics.common), 'Common relics'),
+ metric(String(view.relics.rare), 'Rare relics'),
+ metric(String(view.relics.core), 'Core relics'),
+ ])
+ );
+ }
+ card.append(chain);
+
+ const account = el('section', 'metric-group');
+ account.append(el('h3', 'metric-heading', 'Keep your record'));
+ account.append(
+ el(
+ 'p',
+ 'panel-sub',
+ 'Your record lives in this browser. If you clear its site data or switch browsers, you lose access to it. Copy your recovery code and keep it somewhere private to bring it back.'
+ )
+ );
+ if (view.recoveryNote) account.append(el('p', 'panel-body', view.recoveryNote));
+ if (view.restoreStep === 'enter') {
+ account.append(restoreForm(view));
+ } else if (view.restoreStep === 'confirm') {
+ account.append(restoreConfirm(view));
+ } else {
+ const recovery = el('div', 'result-actions');
+ recovery.append(button('Copy recovery code', 'copyRecovery', 'btn'), button('Restore from a code', 'restoreRecovery', 'btn'));
+ account.append(recovery);
+ }
+ card.append(account);
+
+ const sound = el('section', 'metric-group');
+ sound.append(el('h3', 'metric-heading', 'Sound'));
+ sound.append(volumeSlider('Effects', getFxVolume(), setFxVolume));
+ sound.append(volumeSlider('Ambience', getAmbienceVolume(), setAmbienceVolume));
+ card.append(sound);
+
+ const actions = el('div', 'result-actions sticky-actions');
+ actions.append(button('Replay hints', 'replayHints', 'btn'), button('Back', 'cancel', 'btn'));
+ card.append(actions);
+ return card;
+}
+
+function volumeSlider(label: string, value: number, onChange: (value: number) => void): HTMLElement {
+ const row = el('label', 'volume-row');
+ row.append(el('span', 'volume-label', label));
+ const input = el('input', 'volume-input');
+ input.type = 'range';
+ input.min = '0';
+ input.max = '1';
+ input.step = VOLUME_STEP;
+ input.value = String(value);
+ input.addEventListener('input', () => onChange(Number(input.value)));
+ row.append(input);
+ return row;
+}
diff --git a/client/src/render/title.ts b/client/src/render/title.ts
index 47295db..e7808b2 100644
--- a/client/src/render/title.ts
+++ b/client/src/render/title.ts
@@ -82,7 +82,11 @@ export function mountTitle(host: HTMLElement, info: TitleInfo): void {
const hint = el('p', 'title-hint', 'Enter forges a delver. V the vault. L the leaderboard. M your record. N sound.');
const players = el('a', 'quiet-link', 'All players');
players.href = '/stats';
- content.append(logo, tagline, menu, infoLocal, infoChain, infoVault, hint, players);
+ const docs = el('a', 'quiet-link', 'Docs');
+ docs.href = '/docs';
+ const links = el('div', 'quiet-links');
+ links.append(players, docs);
+ content.append(logo, tagline, menu, infoLocal, infoChain, infoVault, hint, links);
root.append(stage, content);
host.append(root);
diff --git a/client/src/render/vaultCard.ts b/client/src/render/vaultCard.ts
new file mode 100644
index 0000000..60e53ea
--- /dev/null
+++ b/client/src/render/vaultCard.ts
@@ -0,0 +1,87 @@
+import { HEROES, HERO_ORDER } from '../game/heroes';
+import { CHARMS, WEAPONS } from '../game/items';
+import { MAX_RANK } from '../game/loadout';
+import type { Hero } from '../stellar/contract';
+import { el, button } from './dom';
+import { cloakFilter, emblemIcon } from './emblems';
+import { loadingBlock } from './loader';
+import type { VaultView } from './chromeTypes';
+
+/** Vault cards enter one after another so the list reads top down. */
+const CARD_STAGGER_MS = 60;
+
+export function vaultCard(view: VaultView): HTMLElement {
+ const wrap = el('div', 'vault-view');
+ wrap.append(el('div', 'vault-backdrop'));
+
+ const card = el('div', 'panel panel-scroll panel-wide');
+ card.append(el('h2', 'panel-title', 'Your vault'));
+ card.append(
+ el(
+ 'p',
+ 'panel-sub',
+ 'Delvers you earned by clearing all three floors. Each is a token on Stellar that you own, and each can descend again as a veteran.'
+ )
+ );
+
+ if (view.vaultLoading || !view.vaultHeroes) {
+ if (view.vaultLoading) {
+ card.append(loadingBlock('Opening the vault', 2));
+ } else {
+ card.append(el('p', 'panel-body', 'The vault could not be read right now. Your connection or Stellar may be having a moment.'));
+ card.append(button('Try again', 'retryVault', 'btn btn-primary'));
+ }
+ } else if (view.vaultHeroes.length === 0) {
+ card.append(el('p', 'panel-body', 'Empty for now. Clear all three floors with a new delver to claim one.'));
+ } else {
+ const list = el('div', 'vault-list');
+ view.vaultHeroes.forEach((hero, index) => {
+ const item = heroCard(hero, view);
+ item.style.animationDelay = `${index * CARD_STAGGER_MS}ms`;
+ list.append(item);
+ });
+ card.append(list);
+ }
+
+ card.append(button('Back', 'cancel', 'btn'));
+ wrap.append(card);
+ return wrap;
+}
+
+function heroCard(hero: Hero, view: VaultView): HTMLElement {
+ const item = el('article', 'vault-card');
+ const baseId = HERO_ORDER[hero.base] ?? 'lantern';
+ const portrait = el('img', 'vault-portrait');
+ portrait.src = `/art/${HEROES[baseId].veteranArt}`;
+ portrait.alt = '';
+ portrait.style.filter = cloakFilter(hero.look.cloak, hero.base);
+ const copy = el('div', 'vault-copy');
+ const title = el('p', 'vault-name');
+ title.append(emblemIcon(hero.look.emblem, 'emblem emblem-small'), el('span', '', hero.look.name));
+ const kit = `${HEROES[baseId].name} · ${WEAPONS[hero.weapon]?.name ?? '?'} · ${CHARMS[hero.charm]?.name ?? '?'}`;
+ const icons = el('span', 'vault-icons');
+ for (const file of [WEAPONS[hero.weapon]?.icon, CHARMS[hero.charm]?.icon]) {
+ if (!file) continue;
+ const icon = el('img', 'vault-icon');
+ icon.src = `/art/${file}`;
+ icon.alt = '';
+ icons.append(icon);
+ }
+ const rank = Math.min(hero.clears, MAX_RANK);
+ const pips = el('span', 'vault-pips');
+ for (let i = 0; i < MAX_RANK; i += 1) pips.append(el('span', i < rank ? 'vault-pip is-filled' : 'vault-pip'));
+ const rankLine = el('p', 'vault-meta');
+ rankLine.append(pips, document.createTextNode(`Veteran rank ${rank} · ${hero.clears} ${hero.clears === 1 ? 'clear' : 'clears'}`));
+ copy.append(
+ title,
+ el('p', 'vault-kit', kit),
+ icons,
+ rankLine,
+ el('p', 'vault-meta', `First clear: score ${hero.proof_score}, ${hero.proof_hp_left} HP left, ${hero.proof_actions} moves`)
+ );
+ const go = el('button', 'btn btn-primary', 'Descend as a veteran');
+ go.type = 'button';
+ go.addEventListener('click', () => view.onVeteran(hero.id));
+ item.append(portrait, copy, go);
+ return item;
+}
diff --git a/client/src/stats/dom.ts b/client/src/stats/dom.ts
new file mode 100644
index 0000000..ce74fa6
--- /dev/null
+++ b/client/src/stats/dom.ts
@@ -0,0 +1,6 @@
+export function el(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
diff --git a/client/src/stats/render.ts b/client/src/stats/render.ts
index c20126a..f295552 100644
--- a/client/src/stats/render.ts
+++ b/client/src/stats/render.ts
@@ -1,4 +1,6 @@
+import { loadingBlock } from '../render/loader';
import { STELLAR_CONFIG } from '../stellar/config';
+import { el } from './dom';
import { MAX_TRACKED_FLOOR, PLAYER_CAP, type DayCount, type PlayerMark, type RecordedRun, type TractionSummary } from './summary';
const WHEN = new Intl.DateTimeFormat('en-GB', {
@@ -16,17 +18,7 @@ const DAY = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
});
-function el(
- tag: K,
- className?: string,
- text?: string
-): HTMLElementTagNameMap[K] {
- const node = document.createElement(tag);
- if (className) node.className = className;
- if (text !== undefined) node.textContent = text;
- return node;
-}
-
+const LOADING_ROWS = 4;
const STELLAR_VIEW = 'https://stellarview.acachete.xyz/en/testnet';
function viewAccount(address: string): string {
@@ -138,17 +130,28 @@ function runCard(run: RecordedRun): HTMLElement {
}
export function renderLoading(root: HTMLElement): void {
- root.replaceChildren(shell(el('p', 'lede', 'Reading recorded runs from Stellar testnet...')));
+ root.replaceChildren(shell(loadingBlock('Reading recorded runs from Stellar testnet', LOADING_ROWS)));
}
export function renderError(root: HTMLElement, onRetry: () => void): void {
- const body = el('div', 'stack');
- body.append(el('p', 'lede', 'Stellar testnet could not be read from this browser.'));
+ const body = el('div', 'notice');
+ body.setAttribute('role', 'alert');
+ body.append(el('h2', 'section-title', 'Could not read the record'));
+ body.append(
+ el(
+ 'p',
+ 'lede',
+ 'Stellar testnet could not be read from this browser. Your connection or the network may be having a moment. Nothing was lost.'
+ )
+ );
+ const actions = el('div', 'player-actions');
const retry = el('button', 'btn btn-primary', 'Try again');
retry.type = 'button';
retry.addEventListener('click', onRetry);
- body.append(retry);
+ actions.append(retry);
+ body.append(actions);
root.replaceChildren(shell(body));
+ retry.focus();
}
export function renderSummary(root: HTMLElement, summary: TractionSummary): void {
@@ -214,7 +217,9 @@ export function renderSummary(root: HTMLElement, summary: TractionSummary): void
const recent = el('section', 'block');
recent.append(el('h2', 'section-title', 'Latest recorded runs'));
if (summary.recentRunCount > summary.recentRuns.length) {
- recent.append(el('p', 'note', `Showing the latest ${summary.recentRuns.length} of ${summary.recentRunCount} still in Stellar's public history.`));
+ recent.append(
+ el('p', 'note', `Showing the latest ${summary.recentRuns.length} of ${summary.recentRunCount} still in Stellar's public history.`)
+ );
}
const list = el('div', 'player-list');
for (const run of summary.recentRuns) list.append(runCard(run));
@@ -229,10 +234,26 @@ export function renderSummary(root: HTMLElement, summary: TractionSummary): void
'. The contract replays each run from its action log and derives the floor, score and relics itself, so a result cannot be claimed without playing it. A death banks no relics.'
);
body.append(fine);
+ const leave = el('div', 'player-actions');
+ leave.append(backLink(), docsLink());
+ body.append(leave);
root.replaceChildren(shell(body));
}
+/** The way home, always visible: a real button-sized link, not just the small wordmark. */
+function backLink(): HTMLAnchorElement {
+ const link = el('a', 'btn btn-back', 'Back to the game');
+ link.href = '/';
+ return link;
+}
+
+function docsLink(): HTMLAnchorElement {
+ const link = el('a', 'btn', 'Docs');
+ link.href = '/docs/';
+ return link;
+}
+
function shell(content: HTMLElement): HTMLElement {
const page = el('div', 'page');
const header = el('header', 'page-header');
@@ -241,7 +262,12 @@ function shell(content: HTMLElement): HTMLElement {
header.append(brand);
header.append(el('p', 'eyebrow', 'Stellar testnet'));
page.append(header);
+ const nav = el('nav', 'page-nav');
+ nav.setAttribute('aria-label', 'Deepmark');
+ nav.append(backLink(), docsLink());
+ page.append(nav);
const main = el('main');
+ main.setAttribute('aria-busy', content.getAttribute('role') === 'status' ? 'true' : 'false');
main.append(el('h1', 'page-title', 'Recorded play'));
main.append(content);
page.append(main);
diff --git a/client/src/stats/stats.css b/client/src/stats/stats.css
index 72192bd..38fdff9 100644
--- a/client/src/stats/stats.css
+++ b/client/src/stats/stats.css
@@ -218,3 +218,37 @@ button, input { font: inherit; }
@media (prefers-reduced-motion: reduce) {
.bar-fill { transition: none; }
}
+
+.page-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 4px 0 12px;
+}
+
+.page-nav .btn,
+.player-actions .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+}
+
+.btn-back {
+ background: var(--cyan-deep);
+ border-color: rgba(126, 240, 224, 0.45);
+ color: var(--cyan);
+}
+
+.notice {
+ margin-top: 16px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ border: 1px solid rgba(255, 107, 90, 0.4);
+ border-radius: 16px;
+ background: var(--bg-raise);
+}
+
+main > .loader { margin-top: 16px; }
diff --git a/client/src/stats/summary.ts b/client/src/stats/summary.ts
index 9c76a17..a9a402a 100644
--- a/client/src/stats/summary.ts
+++ b/client/src/stats/summary.ts
@@ -127,9 +127,7 @@ export function summarize(
const day = run.at.slice(0, 10);
byDay.set(day, (byDay.get(day) ?? 0) + 1);
}
- const days = eventWindow
- ? utcDays(eventWindow.from, eventWindow.to).map((day) => ({ day, runs: byDay.get(day) ?? 0 }))
- : [];
+ const days = eventWindow ? utcDays(eventWindow.from, eventWindow.to).map((day) => ({ day, runs: byDay.get(day) ?? 0 })) : [];
return {
players: sorted,
playerCount: sorted.length,
diff --git a/client/src/stellar/account.ts b/client/src/stellar/account.ts
index 6a1dd69..c74c463 100644
--- a/client/src/stellar/account.ts
+++ b/client/src/stellar/account.ts
@@ -1,5 +1,7 @@
-import { Asset, BASE_FEE, Keypair, Operation, rpc, TransactionBuilder } from '@stellar/stellar-sdk';
+import { Asset, BASE_FEE, Keypair, Operation, rpc, StrKey, TransactionBuilder } from '@stellar/stellar-sdk';
import { RELIC_ASSETS, RELIC_ISSUER, STELLAR_CONFIG } from './config';
+import { readItem, writeItem } from '../storage';
+import { retryTransient } from './transient';
const STORAGE_KEY = 'deepmark:stellar-secret:v1';
const TRUSTLINE_FLAG_PREFIX = 'deepmark:relic-trustlines:v1:';
@@ -8,7 +10,8 @@ const ACCOUNT_POLL_ATTEMPTS = 10;
const TX_POLL_ATTEMPTS = 20;
const POLL_INTERVAL_MS = 1500;
-let cached: Promise | null = null;
+let preparing: Promise | null = null;
+let memoryKeypair: Keypair | null = null;
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -28,19 +31,12 @@ function trustlineFlagKey(publicKey: string): string {
}
function trustlinesAlreadyAdded(publicKey: string): boolean {
- try {
- return localStorage.getItem(trustlineFlagKey(publicKey)) === '1';
- } catch {
- return false;
- }
+ return readItem(trustlineFlagKey(publicKey)) === '1';
}
function rememberTrustlines(publicKey: string): void {
- try {
- localStorage.setItem(trustlineFlagKey(publicKey), '1');
- } catch {
- // Without storage the step simply repeats next visit. It is idempotent.
- }
+ // Without storage the step simply repeats next visit. It is idempotent.
+ writeItem(trustlineFlagKey(publicKey), '1');
}
/**
@@ -49,9 +45,9 @@ function rememberTrustlines(publicKey: string): void {
* game contract only if the trustline exists, and the contract never loses a
* verified run if it does not, so a failure here is not fatal.
*/
-async function ensureRelicTrustlines(keypair: Keypair): Promise {
+async function ensureRelicTrustlines(keypair: Keypair, force = false): Promise {
const publicKey = keypair.publicKey();
- if (trustlinesAlreadyAdded(publicKey)) return;
+ if (!force && trustlinesAlreadyAdded(publicKey)) return;
const server = new rpc.Server(STELLAR_CONFIG.rpcUrl);
let account = null;
@@ -107,21 +103,70 @@ async function ensureRelicTrustlines(keypair: Keypair): Promise {
* client depends on how the keypair was obtained.
*/
export function getPlayerKeypair(): Promise {
- if (!cached) {
- cached = (async () => {
- const existingSecret = localStorage.getItem(STORAGE_KEY);
- const keypair = existingSecret ? Keypair.fromSecret(existingSecret) : Keypair.random();
- if (!existingSecret) localStorage.setItem(STORAGE_KEY, keypair.secret());
-
- try {
- await fundOnTestnet(keypair.publicKey());
- await ensureRelicTrustlines(keypair);
- } catch (err) {
- console.warn('Deepmark: account preparation did not finish', err);
- }
-
- return keypair;
- })();
+ preparing ??= prepareAccount();
+ return preparing;
+}
+
+/**
+ * Redoes the account's relic setup even if it was marked done, for a player
+ * whose relic mint was refused. Adding a trustline that exists is harmless.
+ * Throws if the setup could not finish, so the caller can say so.
+ */
+export async function prepareRelicSetup(): Promise {
+ const keypair = await getPlayerKeypair();
+ await retryTransient(() => fundOnTestnet(keypair.publicKey()));
+ await retryTransient(() => ensureRelicTrustlines(keypair, true));
+}
+
+async function prepareAccount(): Promise {
+ const keypair = loadOrCreateKeypair();
+ try {
+ await retryTransient(() => fundOnTestnet(keypair.publicKey()));
+ await retryTransient(() => ensureRelicTrustlines(keypair));
+ } catch (err) {
+ console.warn('Deepmark: account preparation did not finish', err);
+ // Forget this attempt so the next caller retries funding in the same session.
+ preparing = null;
}
- return cached;
+ return keypair;
+}
+
+function loadOrCreateKeypair(): Keypair {
+ if (memoryKeypair) return memoryKeypair;
+ const existingSecret = localStorage.getItem(STORAGE_KEY);
+ const keypair = existingSecret ? Keypair.fromSecret(existingSecret) : Keypair.random();
+ if (!existingSecret) localStorage.setItem(STORAGE_KEY, keypair.secret());
+ memoryKeypair = keypair;
+ return keypair;
+}
+
+/**
+ * The account's recovery code, for the player to keep somewhere safe. It is
+ * the only way to get this account back after the browser's site data is
+ * cleared. Returns null if this browser holds no account yet. Callers must
+ * hand it straight to the clipboard and never log or render it.
+ */
+export function getRecoveryCode(): string | null {
+ return memoryKeypair?.secret() ?? localStorage.getItem(STORAGE_KEY);
+}
+
+/** True when the text is a well formed recovery code. */
+export function isRecoveryCode(code: string): boolean {
+ return StrKey.isValidEd25519SecretSeed(code);
+}
+
+/** The public address a recovery code belongs to, safe to show. Throws on an invalid code. */
+export function recoveryCodeAddress(code: string): string {
+ const trimmed = code.trim();
+ if (!isRecoveryCode(trimmed)) throw new Error('That recovery code is not valid');
+ return Keypair.fromSecret(trimmed).publicKey();
+}
+
+/** Replaces this browser's account with one restored from a recovery code. The page should reload after. */
+export function restoreRecoveryCode(code: string): void {
+ const trimmed = code.trim();
+ if (!isRecoveryCode(trimmed)) throw new Error('That recovery code is not valid');
+ localStorage.setItem(STORAGE_KEY, trimmed);
+ memoryKeypair = null;
+ preparing = null;
}
diff --git a/client/src/stellar/choice.ts b/client/src/stellar/choice.ts
index 2110798..2653b2e 100644
--- a/client/src/stellar/choice.ts
+++ b/client/src/stellar/choice.ts
@@ -29,9 +29,11 @@ export const CONTRACT_ERROR = {
InvalidName: 9,
HeroNotFound: 10,
NotHeroOwner: 11,
+ Frozen: 12,
+ NotInitialized: 13,
+ MintFailed: 14,
} as const;
export function isContractError(err: unknown, code: number): boolean {
return err instanceof Error && err.message.includes(`Error(Contract, #${code})`);
}
-
diff --git a/client/src/stellar/config.ts b/client/src/stellar/config.ts
index 305e58f..f219468 100644
--- a/client/src/stellar/config.ts
+++ b/client/src/stellar/config.ts
@@ -4,7 +4,7 @@ export const STELLAR_CONFIG = {
horizonUrl: 'https://horizon-testnet.stellar.org',
friendbotUrl: 'https://friendbot.stellar.org',
explorerUrl: 'https://stellar.expert/explorer/testnet',
- contractId: 'CDEAFSBG7JBN6XMP4HRL7LFBL7L67K4RJK3CIRBV23W3TJQCEADZ7R6N',
+ contractId: 'CDQDI2SNKQHPC7YW7YCW7USYLHXW2XTV5JBXWC24K53KDMY43DBHH7RX',
};
export type RelicKind = 'common' | 'rare' | 'core';
@@ -16,12 +16,12 @@ export type RelicKind = 'common' | 'rare' | 'core';
* mint one. Verified on Horizon when this deployment was made.
*/
export const RELIC_ASSETS: Record = {
- common: { code: 'DMCOMMON', contractId: 'CAAFYL2QZU274NLA2IFDB4L6SUNVQKT6OLB6UXGL5L5LMQCFJXSUYFO3' },
- rare: { code: 'DMRARE', contractId: 'CC4RQ4HLTPTXMCZ3OKDL546XXJGOQ5PI2ET6U33C2XTO4BTS6AJV4ANA' },
- core: { code: 'DMCORE', contractId: 'CC27JVKNDSUM5FDGP4JZDZTKGHJ5RJV7XHJGITTUGOUFE4DVPN6PGLMI' },
+ common: { code: 'DMCOMMON', contractId: 'CDK5SL4OP6CAFZPFJ2YOTSBN3NDHCJ4A23UA4TXIZP5DFO7RT6IH4S5R' },
+ rare: { code: 'DMRARE', contractId: 'CCQFEYZR3D4XHTMLV3XJZBTZUT4WXZS2LUXHPFTH7DRRZENZNU2NVZ5L' },
+ core: { code: 'DMCORE', contractId: 'CAHKK75BOXGBKSXBB3EFPWS6C3IZIXIFLYI3A74GYCRSI4U6NKVZTLIM' },
};
-export const RELIC_ISSUER = 'GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK';
+export const RELIC_ISSUER = 'GAEZFWDURKH2UF5DF5S26AXHLTUE4NNQ4YDDROFRXNTDO46THSXJFA7Q';
/** Classic assets carry seven decimals. The contract mints whole relics. */
export const RELIC_UNIT = 10_000_000n;
diff --git a/client/src/stellar/contract.ts b/client/src/stellar/contract.ts
index b075b77..82ed56a 100644
--- a/client/src/stellar/contract.ts
+++ b/client/src/stellar/contract.ts
@@ -1,5 +1,6 @@
import { contract, TransactionBuilder, type Keypair, type Transaction } from '@stellar/stellar-sdk';
import type { HeroChoice } from './choice';
+import { readSentResult } from './txOutcome';
import { STELLAR_CONFIG } from './config';
export interface Look {
@@ -97,10 +98,7 @@ type Call = Promise>;
// contract.
interface DeepmarkContractApi {
start_run: (args: { player: string; choice: HeroChoice }, options?: contract.MethodOptions) => Call>;
- finish_run: (
- args: { player: string; actions: Uint8Array },
- options?: contract.MethodOptions
- ) => Call>;
+ finish_run: (args: { player: string; actions: Uint8Array }, options?: contract.MethodOptions) => Call>;
get_record: (args: { player: string }, options?: contract.MethodOptions) => Call;
get_open_run: (args: { player: string }, options?: contract.MethodOptions) => Call;
top_fresh: (options?: contract.MethodOptions) => Call;
@@ -155,12 +153,9 @@ export { CONTRACT_ERROR, freshChoice, isContractError, veteranChoice } from './c
*/
export async function startRun(keypair: Keypair, choice: HeroChoice): Promise {
const client = await clientFor(keypair);
- const assembled = await client.start_run(
- { player: keypair.publicKey(), choice },
- { timeoutInSeconds: SEND_TIMEOUT_SECONDS }
- );
+ const assembled = await client.start_run({ player: keypair.publicKey(), choice }, { timeoutInSeconds: SEND_TIMEOUT_SECONDS });
const sent = await assembled.signAndSend();
- const result = sent.result;
+ const result = readSentResult(sent);
if (result.isErr()) throw new Error(`start_run rejected: ${result.unwrapErr().message}`);
return result.unwrap();
}
@@ -182,7 +177,7 @@ export async function finishRun(keypair: Keypair, actions: readonly number[]): P
{ timeoutInSeconds: SEND_TIMEOUT_SECONDS }
);
const sent = await assembled.signAndSend();
- const result = sent.result;
+ const result = readSentResult(sent);
if (result.isErr()) throw new Error(`finish_run rejected: ${result.unwrapErr().message}`);
return { hash: sent.sendTransactionResponse?.hash ?? '', outcome: result.unwrap() };
}
@@ -225,6 +220,11 @@ export async function fetchPlayers(start: number, limit: number): Promise {
+ const client = await readClient();
+ return (await client.hero({ id })).result ?? null;
+}
+
/** Every delver an address owns. At most 64, so one call per delver is bounded. */
export async function fetchHeroes(owner: string): Promise {
const client = await readClient();
diff --git a/client/src/stellar/lazy.ts b/client/src/stellar/lazy.ts
index 760bf76..56da581 100644
--- a/client/src/stellar/lazy.ts
+++ b/client/src/stellar/lazy.ts
@@ -1,5 +1,9 @@
type Api = typeof import('./api');
+/** The chunk also starts account preparation, so it should begin soon after first paint. */
+const IDLE_TIMEOUT_MS = 600;
+const FALLBACK_DELAY_MS = 250;
+
let loading: Promise | null = null;
/** Loads the Stellar SDK chunk the first time it is needed. Later calls reuse it. */
@@ -14,6 +18,6 @@ export function chain(): Promise {
*/
export function warmChain(onReady?: () => void): void {
const start = (): void => void chain().then(() => onReady?.());
- if (typeof requestIdleCallback === 'function') requestIdleCallback(start, { timeout: 2500 });
- else setTimeout(start, 1200);
+ if (typeof requestIdleCallback === 'function') requestIdleCallback(start, { timeout: IDLE_TIMEOUT_MS });
+ else setTimeout(start, FALLBACK_DELAY_MS);
}
diff --git a/client/src/stellar/traction.ts b/client/src/stellar/traction.ts
index 4df5ee9..84438f8 100644
--- a/client/src/stellar/traction.ts
+++ b/client/src/stellar/traction.ts
@@ -26,14 +26,7 @@ function playerMark(address: unknown, raw: PlayerRecord | null | undefined): Pla
const deaths = count(raw.deaths);
const forfeits = count(raw.forfeits);
const relicsBanked = count(raw.relics_banked);
- if (
- bestFloor === null ||
- bestScore === null ||
- extractions === null ||
- deaths === null ||
- forfeits === null ||
- relicsBanked === null
- ) {
+ if (bestFloor === null || bestScore === null || extractions === null || deaths === null || forfeits === null || relicsBanked === null) {
return null;
}
const finishedRuns = extractions + deaths;
@@ -60,9 +53,7 @@ function parseRun(event: rpc.Api.EventResponse): RecordedRun | null {
if (name !== 'run_finished') return null;
const address = event.topic[1] ? scValToNative(event.topic[1]) : null;
if (!isAddress(address)) return null;
- const value = scValToNative(event.value) as
- | { floor_reached?: unknown; score?: unknown; extracted?: unknown }
- | null;
+ const value = scValToNative(event.value) as { floor_reached?: unknown; score?: unknown; extracted?: unknown } | null;
if (!value || typeof value !== 'object') return null;
const floorReached = count(value.floor_reached, 3);
const score = count(value.score, 6);
diff --git a/client/src/stellar/transient.ts b/client/src/stellar/transient.ts
new file mode 100644
index 0000000..2db2d15
--- /dev/null
+++ b/client/src/stellar/transient.ts
@@ -0,0 +1,65 @@
+/**
+ * Tells a passing network blip from a real refusal. Kept free of the Stellar
+ * SDK so both the game and the account setup can share it.
+ */
+import { TRANSIENT_BACKOFF_MS, TRANSIENT_JITTER_MS, TRANSIENT_RETRIES } from '../constants';
+
+/** HTTP statuses that mean "try again in a moment". */
+const TRANSIENT_STATUSES: readonly number[] = [429, 500, 502, 503, 504];
+
+const STATUS_IN_TEXT = /\b(429|500|502|503|504)\b/;
+const NETWORK_TEXT =
+ /failed to fetch|network ?error|network request failed|load failed|econnreset|econnrefused|etimedout|socket hang up|timed out|timeout|try_again_later|fetch failed/i;
+
+/** A contract refusal is a real answer from the chain, never a blip. */
+function isContractRefusal(message: string): boolean {
+ return message.includes('Error(Contract, #');
+}
+
+function statusOf(err: unknown): number | null {
+ if (typeof err !== 'object' || err === null) return null;
+ const shaped = err as { status?: unknown; response?: { status?: unknown } };
+ if (typeof shaped.status === 'number') return shaped.status;
+ if (typeof shaped.response?.status === 'number') return shaped.response.status;
+ return null;
+}
+
+/** True for a send that failed on the way (HTTP 429/5xx, reset, timeout) and may well work if repeated. */
+export function isTransientError(err: unknown): boolean {
+ const status = statusOf(err);
+ if (status !== null) return TRANSIENT_STATUSES.includes(status);
+ const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
+ if (!message || isContractRefusal(message)) return false;
+ return STATUS_IN_TEXT.test(message) || NETWORK_TEXT.test(message);
+}
+
+export interface RetryOptions {
+ retries?: number;
+ backoffMs?: number;
+ jitterMs?: number;
+ /** Stops retrying (and returns the last error) once this says so, for example when the player skipped the wait. */
+ cancelled?: () => boolean;
+ sleep?: (ms: number) => Promise;
+ random?: () => number;
+}
+
+function defaultSleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/** Runs `job`, repeating it after a jittered, growing wait while it fails with a transient error. */
+export async function retryTransient(job: () => Promise, options: RetryOptions = {}): Promise {
+ const retries = options.retries ?? TRANSIENT_RETRIES;
+ const backoff = options.backoffMs ?? TRANSIENT_BACKOFF_MS;
+ const jitter = options.jitterMs ?? TRANSIENT_JITTER_MS;
+ const wait = options.sleep ?? defaultSleep;
+ const random = options.random ?? Math.random;
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await job();
+ } catch (err) {
+ if (attempt >= retries || !isTransientError(err) || options.cancelled?.()) throw err;
+ await wait(backoff * (attempt + 1) + random() * jitter);
+ }
+ }
+}
diff --git a/client/src/stellar/txOutcome.ts b/client/src/stellar/txOutcome.ts
new file mode 100644
index 0000000..48f1aaa
--- /dev/null
+++ b/client/src/stellar/txOutcome.ts
@@ -0,0 +1,59 @@
+/**
+ * Reads the outcome of a submitted transaction without trusting its shape.
+ * Kept free of the Stellar SDK so the game can classify failures without
+ * loading the heavy chunk.
+ */
+
+/** The part of a settled transaction response this module relies on. */
+export interface SettledResponse {
+ status?: string;
+ returnValue?: unknown;
+}
+
+/** The part of the SDK's `SentTransaction` this module relies on. */
+export interface SentLike {
+ getTransactionResponse?: SettledResponse | null;
+ readonly result: T;
+}
+
+const STATUS_SUCCESS = 'SUCCESS';
+const STATUS_FAILED = 'FAILED';
+
+/**
+ * A transaction that was submitted but did not produce a usable result.
+ * `retryable` is true only when the transaction is known to have failed on
+ * chain, which leaves no effect behind, so submitting a fresh one is safe.
+ */
+export class TxOutcomeError extends Error {
+ readonly retryable: boolean;
+
+ constructor(message: string, retryable: boolean) {
+ super(message);
+ this.name = 'TxOutcomeError';
+ this.retryable = retryable;
+ }
+}
+
+/**
+ * Returns the parsed result of a sent transaction, or throws a `TxOutcomeError`.
+ * The SDK parses `returnValue` blindly, so a failed transaction (for example a
+ * footprint clash when several players start in the same ledger) would
+ * otherwise surface as "Cannot read properties of undefined".
+ */
+export function readSentResult(sent: SentLike): T {
+ const response = sent.getTransactionResponse;
+ if (response) {
+ if (response.status === STATUS_FAILED) {
+ throw new TxOutcomeError('The transaction failed on the network and had no effect', true);
+ }
+ if (response.status !== STATUS_SUCCESS || response.returnValue === undefined || response.returnValue === null) {
+ throw new TxOutcomeError('The network did not return a result for the transaction', false);
+ }
+ }
+ return sent.result;
+}
+
+/** True when the error is a failed transaction that is safe to submit again. */
+export function isRetryableTxFailure(err: unknown): boolean {
+ return err instanceof TxOutcomeError && err.retryable;
+}
diff --git a/client/src/storage.ts b/client/src/storage.ts
new file mode 100644
index 0000000..9060243
--- /dev/null
+++ b/client/src/storage.ts
@@ -0,0 +1,52 @@
+/**
+ * The only place that touches `localStorage` for optional, per-browser state.
+ * Storage can be missing or throw (private mode, blocked site data, quota), and
+ * none of the callers may crash the game because of it, so every access returns
+ * a value that says what happened instead of throwing. Callers that must not
+ * lose data silently (account secrets) do not use this module.
+ */
+
+/** Reads a raw string, or null when the key is absent or storage is unavailable. */
+export function readItem(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+/** Writes a raw string. Returns false when it could not be persisted. */
+export function writeItem(key: string, value: string): boolean {
+ try {
+ localStorage.setItem(key, value);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Removes a key. Returns false when storage is unavailable. */
+export function removeItem(key: string): boolean {
+ try {
+ localStorage.removeItem(key);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Reads and parses JSON. Missing, unparsable or unavailable all give null. The caller validates the shape. */
+export function readJson(key: string): unknown {
+ const raw = readItem(key);
+ if (raw === null) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+/** Serializes and writes JSON. Returns false when it could not be persisted. */
+export function writeJson(key: string, value: unknown): boolean {
+ return writeItem(key, JSON.stringify(value));
+}
diff --git a/client/src/styles.css b/client/src/styles.css
index 85ccacc..0594d42 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -12,6 +12,11 @@
--danger: #ff6b5a;
--safe: #8ee0a8;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
+ /* The fixed top bar (44px sound button plus its padding) and the sticky action
+ bar (48px buttons plus padding). Overlay screens reserve both so nothing
+ paints under the bar and the last row scrolls clear of the actions. */
+ --topbar-h: calc(64px + env(safe-area-inset-top));
+ --actions-h: calc(76px + env(safe-area-inset-bottom));
font-family: "Avenir Next", "Segoe UI", sans-serif;
}
@@ -42,6 +47,7 @@ button, input { font: inherit; }
.topbar {
position: relative;
z-index: 6;
+ min-height: var(--topbar-h);
display: flex;
align-items: baseline;
justify-content: space-between;
@@ -270,7 +276,41 @@ body:has(#overlay:not([hidden])) .touch {
gap: 8px;
}
-.panel-scroll { max-height: 100%; overflow: auto; }
+/* The overlay scrolls, not the card, so a sticky action bar pins to the screen. */
+.panel-scroll { max-height: none; }
+
+/* Cards that sit under the top bar (creator, vault, leaderboard, record): a
+ column that scrolls, offset by the bar, with the card centred by auto margins
+ so a card taller than the screen starts at the top instead of being clipped. */
+.overlay:has(> .panel, > .vault-view) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ scroll-padding: var(--topbar-h) 0 var(--actions-h);
+ padding: calc(var(--topbar-h) + 8px) calc(16px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left));
+}
+.overlay:has(> .panel, > .vault-view) > .panel { flex: none; margin: auto; }
+.overlay:has(> .vault-view) > .vault-view { flex: 1 0 auto; }
+
+/* Scrolled content passes under the top bar, so the bar backs itself with the page colour. */
+body:has(#overlay > .panel, #overlay > .vault-view) .topbar {
+ background: linear-gradient(180deg, var(--bg) 78%, rgba(7, 8, 14, 0));
+}
+
+/* The way out of a long screen stays pinned to the bottom edge. */
+.sticky-actions {
+ position: sticky;
+ bottom: 0;
+ z-index: 2;
+ min-height: var(--actions-h);
+ padding: 12px 0 calc(8px + env(safe-area-inset-bottom));
+ background: linear-gradient(180deg, rgba(10, 13, 21, 0) 0%, rgba(10, 13, 21, 0.94) 32%);
+}
+.sticky-actions .btn { min-height: 48px; }
.prompt-danger { border-color: rgba(255, 107, 90, 0.45); }
.prompt-safe { border-color: rgba(142, 224, 168, 0.4); }
@@ -335,11 +375,19 @@ body:has(#overlay:not([hidden])) .touch {
.quiet-link {
display: inline-flex;
align-items: center;
+ justify-content: center;
min-height: 44px;
+ min-width: 44px;
color: var(--cyan);
text-decoration: none;
}
+.quiet-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 20px;
+}
+
.quiet-link:hover { text-decoration: underline; }
.quiet-link:focus-visible {
@@ -751,20 +799,13 @@ body:has(#overlay:not([hidden])) .touch {
/* The preview stays in view while choosing, and the action row stays reachable. */
.creator-preview {
position: sticky;
- top: -16px;
+ top: var(--topbar-h);
z-index: 2;
margin: 0 -4px;
backdrop-filter: blur(6px);
background: rgba(10, 12, 20, 0.92);
}
-.creator .title-actions {
- position: sticky;
- bottom: -16px;
- z-index: 2;
- padding: 10px 0 2px;
- background: linear-gradient(180deg, rgba(12, 16, 26, 0), rgba(12, 16, 26, 0.96) 35%);
-}
/* Descent scene */
.descent {
@@ -1082,12 +1123,10 @@ body:has(#overlay:not([hidden])) .touch {
14 second settle is a compositor-only animation instead of a repaint on every frame.
The loss screen has no image backdrop here: it sinks the last drawn board frame
instead (see `canvas.board-sunk` and `.overlay:has(.result-loss)` further down). */
-.overlay:has(.result-win) {
- overflow: hidden;
-}
.overlay:has(.result-win)::before {
content: "";
- position: absolute;
+ position: fixed;
+ pointer-events: none;
inset: -6% 0;
z-index: -1;
background-size: cover;
@@ -1105,6 +1144,25 @@ body:has(#overlay:not([hidden])) .touch {
}
.result { display: flex; flex-direction: column; gap: 10px; width: min(100%, 520px); }
+
+/* The result screen scrolls on any short or narrow viewport. A grid with
+ centred items clips the top of an overflowing card where no scroll can reach
+ it, so the overlay is a column and the card centres itself with `margin: auto`,
+ which falls back to the top edge when the card is taller than the screen.
+ It sits over the top bar (z-index 7, as the title does) so nothing scrolls
+ under the sound button. */
+.overlay:has(.result) {
+ z-index: 7;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ padding: calc(16px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left));
+}
+.overlay:has(.result) > .result { flex: none; margin: auto; }
/* The board sinks first; the card follows 300ms later instead of both at once. */
.result-loss { opacity: 0; animation: rise 500ms ease 300ms both; }
.result-eyebrow { margin: 0; color: var(--muted); font-size: 0.74rem; letter-spacing: 0.22em; text-transform: uppercase; animation: rise 600ms ease both; }
@@ -1172,8 +1230,8 @@ body:has(#overlay:not([hidden])) .touch {
.share-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; animation: rise 500ms ease both; }
-.result-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
-
+.result-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 4px; }
+.result-actions .btn { flex: 1 1 140px; min-height: 48px; }
@media (max-width: 520px) {
.result-stats { grid-template-columns: repeat(2, 1fr); }
.seal { width: 52px; height: 52px; }
@@ -1333,3 +1391,71 @@ body:has(#overlay:not([hidden])) .touch {
.pick-icon { width: 56px; height: 56px; object-fit: contain; flex: 0 0 auto; filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5)); }
.vault-icons { display: flex; gap: 6px; margin: 6px 0 2px; }
.vault-icon { width: 30px; height: 30px; object-fit: contain; opacity: 0.9; }
+
+/* The calm way out of a long wait on the descent scene. */
+.descent-slow { margin: 0; max-width: 44ch; color: var(--muted); font-size: 0.85rem; }
+.descent-slow[hidden], .descent-skip[hidden], .record-nudge[hidden] { display: none; }
+.descent-skip { margin-top: 4px; }
+.record-nudge { margin: 0; color: var(--muted); font-size: 0.85rem; }
+
+/* Phones. Nothing floats over the page title, the board or the controls. */
+.floor-label:empty { display: none; }
+
+/* Scrolled cards pass under the bar, so it is solid instead of a fade that lets text show through. */
+body:has(#overlay > .panel, #overlay > .vault-view) .topbar {
+ background: var(--bg);
+ box-shadow: 0 8px 12px -8px rgba(0, 0, 0, 0.7);
+}
+
+@media (max-width: 899px) {
+ .topbar { flex-wrap: wrap; row-gap: 0; }
+ .floor-label { order: 3; flex: 1 0 100%; font-size: 0.78rem; margin-top: -6px; }
+ /* The board sits in the middle of the space above the controls, so there is no empty block under the log. */
+ .stage-wrap { flex: 1 1 auto; place-items: center; }
+ .hud { padding-top: 6px; }
+ .hud-log { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-height: 2.6em; }
+ /* The first-run hint sits in the free space above the controls, not on the board. */
+ .hint-card {
+ position: fixed;
+ left: 12px;
+ right: 12px;
+ bottom: calc(160px + env(safe-area-inset-bottom));
+ max-width: none;
+ z-index: 4;
+ }
+}
+
+/* Short, wide screens (a phone on its side): board on the left, status and controls on the right, nothing below the fold. */
+@media (max-width: 899px) and (orientation: landscape) and (max-height: 520px) {
+ #app {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 250px;
+ grid-template-rows: auto minmax(0, 1fr);
+ height: 100dvh;
+ min-height: 0;
+ overflow: hidden;
+ }
+ .topbar { grid-column: 1 / -1; grid-row: 1; flex-wrap: nowrap; padding-bottom: 2px; }
+ .floor-label { order: 0; flex: 0 1 auto; margin: 0; }
+ .stage-wrap { grid-column: 1; grid-row: 2; padding: 0 8px 8px; place-items: center; }
+ .stage { width: min(100%, calc((100dvh - 80px) * 832 / 576)); }
+ .hud, .touch { grid-column: 2; grid-row: 2; width: auto; margin: 0; padding: 0 12px 8px 4px; }
+ .hud { align-self: start; }
+ .hud-stats { gap: 2px 14px; }
+ #hud-name { flex-basis: 100%; }
+ .hud-stats > * { white-space: nowrap; }
+ .hud-key { display: none; }
+ .hud-log { font-size: 0.8rem; -webkit-line-clamp: 3; min-height: 0; }
+ .touch { align-self: end; margin-top: 0; gap: 10px; }
+ .pad { grid-template-columns: repeat(3, 46px); grid-template-rows: repeat(2, 46px); gap: 4px; }
+ .pad-btn, .touch-actions .btn { min-width: 46px; min-height: 46px; }
+ .touch-actions { gap: 6px; }
+ .hint-card { left: auto; right: 8px; width: 234px; bottom: 128px; padding: 6px 10px; }
+ .hint-body { font-size: 0.78rem; }
+ /* The creator's preview scrolls with the form here, so it never covers the Name field. */
+ .creator-preview { position: static; }
+ /* The title keeps its menu on screen. */
+ .title-logo { width: auto; max-height: 30dvh; }
+ .title-content { gap: 8px; padding-top: 12px; padding-bottom: 12px; }
+ .title-hint { display: none; }
+}
diff --git a/client/tests/account.test.ts b/client/tests/account.test.ts
new file mode 100644
index 0000000..a48191d
--- /dev/null
+++ b/client/tests/account.test.ts
@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, test } from 'bun:test';
+import { Keypair } from '@stellar/stellar-sdk';
+import { getRecoveryCode, isRecoveryCode, recoveryCodeAddress, restoreRecoveryCode } from '../src/stellar/account';
+import { installMemoryStorage } from './storage';
+
+describe('recovery code', () => {
+ beforeEach(() => {
+ installMemoryStorage();
+ });
+
+ test('accepts a real secret seed and rejects other text', () => {
+ expect(isRecoveryCode(Keypair.random().secret())).toBe(true);
+ expect(isRecoveryCode(Keypair.random().publicKey())).toBe(false);
+ expect(isRecoveryCode('not a code')).toBe(false);
+ });
+
+ test('restoring stores the code and rejects invalid input', () => {
+ const code = Keypair.random().secret();
+ restoreRecoveryCode(` ${code}\n`);
+ expect(getRecoveryCode()).toBe(code);
+ expect(() => restoreRecoveryCode('nope')).toThrow();
+ });
+
+ test('a rejected code leaves the current account untouched', () => {
+ const current = Keypair.random().secret();
+ restoreRecoveryCode(current);
+ for (const bad of ['', ' ', 'nope', Keypair.random().publicKey(), `${current}x`, current.slice(1)]) {
+ expect(() => restoreRecoveryCode(bad)).toThrow();
+ }
+ expect(getRecoveryCode()).toBe(current);
+ });
+
+ test('the shown address matches the code and never contains the secret', () => {
+ const pair = Keypair.random();
+ const address = recoveryCodeAddress(` ${pair.secret()} `);
+ expect(address).toBe(pair.publicKey());
+ expect(address).not.toContain(pair.secret());
+ expect(() => recoveryCodeAddress('nope')).toThrow();
+ });
+});
diff --git a/client/tests/chainQueue.test.ts b/client/tests/chainQueue.test.ts
new file mode 100644
index 0000000..306fe7f
--- /dev/null
+++ b/client/tests/chainQueue.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, test } from 'bun:test';
+import { ChainQueue, sleep, withTimeout } from '../src/app/chainQueue';
+import { describeFailure, type FailureKind, failureKind, isPermanentFailure, keepsRun, SettlementUnconfirmed } from '../src/app/failures';
+import { CONTRACT_ERROR } from '../src/stellar/choice';
+
+const contractError = (code: number): Error => new Error(`HostError: Error(Contract, #${code})`);
+
+describe('ChainQueue', () => {
+ test('runs jobs one at a time, in order', async () => {
+ const queue = new ChainQueue();
+ const log: string[] = [];
+ const job = (name: string, ms: number) => async () => {
+ log.push(`${name}:start`);
+ await sleep(ms);
+ log.push(`${name}:end`);
+ return name;
+ };
+ const results = await Promise.all([queue.enqueue(job('a', 20)), queue.enqueue(job('b', 1)), queue.enqueue(job('c', 1))]);
+ expect(results).toEqual(['a', 'b', 'c']);
+ expect(log).toEqual(['a:start', 'a:end', 'b:start', 'b:end', 'c:start', 'c:end']);
+ });
+
+ test('a job holds the queue through its whole retry loop, sleeps included', async () => {
+ const queue = new ChainQueue();
+ const log: string[] = [];
+ const finishRun = queue.enqueue(async () => {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ log.push(`finish:${attempt}`);
+ await sleep(5);
+ }
+ });
+ const startRun = queue.enqueue(async () => {
+ log.push('start');
+ });
+ await Promise.all([finishRun, startRun]);
+ expect(log).toEqual(['finish:0', 'finish:1', 'finish:2', 'start']);
+ });
+
+ test('a failed job rejects its own caller but does not block the next one', async () => {
+ const queue = new ChainQueue();
+ const failing = queue.enqueue(async () => {
+ throw new Error('boom');
+ });
+ const next = queue.enqueue(async () => 'ok');
+ await expect(failing).rejects.toThrow('boom');
+ expect(await next).toBe('ok');
+ });
+});
+
+describe('withTimeout', () => {
+ test('passes through a value that arrives in time', async () => {
+ expect(await withTimeout(Promise.resolve(7), 50)).toBe(7);
+ });
+
+ test('passes through the original rejection', async () => {
+ await expect(withTimeout(Promise.reject(new Error('nope')), 50)).rejects.toThrow('nope');
+ });
+
+ test('rejects when the promise is too slow', async () => {
+ await expect(withTimeout(sleep(100), 5)).rejects.toThrow('Timed out');
+ });
+});
+
+describe('failure classification', () => {
+ test('contract refusals are permanent, except TooFast which heals by waiting', () => {
+ expect(isPermanentFailure(contractError(CONTRACT_ERROR.RunNotStarted))).toBe(true);
+ expect(isPermanentFailure(contractError(CONTRACT_ERROR.InvalidAction))).toBe(true);
+ expect(isPermanentFailure(contractError(CONTRACT_ERROR.TooFast))).toBe(false);
+ });
+
+ test('network trouble and non errors are not permanent', () => {
+ expect(isPermanentFailure(new Error('fetch failed'))).toBe(false);
+ expect(isPermanentFailure('Error(Contract, #2)')).toBe(false);
+ });
+
+ test('each refusal gets its own sentence, anything else says Stellar was unreachable', () => {
+ expect(describeFailure(contractError(CONTRACT_ERROR.RunNotStarted))).toContain('no longer open');
+ expect(describeFailure(contractError(CONTRACT_ERROR.NotHeroOwner))).toContain('changed hands');
+ expect(describeFailure(contractError(CONTRACT_ERROR.TrailingActions))).toContain('could not reproduce');
+ expect(describeFailure(new Error('timeout'))).toContain('could not be reached');
+ });
+});
+
+describe('failure kinds, one per contract code', () => {
+ const expected: Record = {
+ 1: 'permanent',
+ 2: 'closed',
+ 3: 'permanent',
+ 4: 'permanent',
+ 5: 'permanent',
+ 6: 'permanent',
+ 7: 'wait',
+ 8: 'permanent',
+ 9: 'permanent',
+ 10: 'permanent',
+ 11: 'permanent',
+ 12: 'permanent',
+ 13: 'notReady',
+ 14: 'mint',
+ };
+ for (const [code, kind] of Object.entries(expected)) {
+ test(`code ${code} is ${kind}`, () => {
+ expect(failureKind(contractError(Number(code)))).toBe(kind);
+ });
+ }
+
+ test('a rejected mint keeps the run and can be retried', () => {
+ const err = contractError(CONTRACT_ERROR.MintFailed);
+ expect(isPermanentFailure(err)).toBe(false);
+ expect(keepsRun(err)).toBe(true);
+ expect(describeFailure(err)).toContain('relics could not be delivered');
+ });
+
+ test('an uninitialised contract keeps the run and says so', () => {
+ const err = contractError(CONTRACT_ERROR.NotInitialized);
+ expect(isPermanentFailure(err)).toBe(false);
+ expect(keepsRun(err)).toBe(true);
+ expect(describeFailure(err)).toContain('not ready');
+ });
+
+ test('a refusal that cannot heal drops the run', () => {
+ expect(keepsRun(contractError(CONTRACT_ERROR.InvalidAction))).toBe(false);
+ expect(keepsRun(contractError(CONTRACT_ERROR.RunNotStarted))).toBe(false);
+ });
+
+ test('an unconfirmed settlement is retryable and honest', () => {
+ const err = new SettlementUnconfirmed();
+ expect(failureKind(err)).toBe('wait');
+ expect(keepsRun(err)).toBe(true);
+ expect(describeFailure(err)).toContain('may already be recorded');
+ });
+});
diff --git a/client/tests/delverOutcome.test.ts b/client/tests/delverOutcome.test.ts
new file mode 100644
index 0000000..bddbe67
--- /dev/null
+++ b/client/tests/delverOutcome.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from 'bun:test';
+import { delverGrewKicker, delverOutcome } from '../src/game/delverOutcome';
+
+describe('delverOutcome', () => {
+ test('no hero id means nothing happened to a delver', () => {
+ expect(delverOutcome(null, false, null)).toEqual({ kind: 'none' });
+ expect(delverOutcome(null, true, null)).toEqual({ kind: 'none' });
+ });
+
+ test('a fresh delver clearing all floors earns a new delver', () => {
+ expect(delverOutcome(4, false, null)).toEqual({ kind: 'earned' });
+ });
+
+ test('a veteran clear never claims a new delver, only a grown record', () => {
+ expect(delverOutcome(1, true, 2)).toEqual({ kind: 'grew', clears: 2 });
+ expect(delverOutcome(1, true, null)).toEqual({ kind: 'grew', clears: null });
+ });
+
+ test('the veteran kicker is accurate and never says a new delver', () => {
+ expect(delverGrewKicker(2)).toBe("Your delver's record grew: 2 clears");
+ expect(delverGrewKicker(1)).toBe("Your delver's record grew: 1 clear");
+ expect(delverGrewKicker(null)).toBe("Your delver's record grew");
+ expect(delverGrewKicker(3).toLowerCase()).not.toContain('new delver');
+ });
+});
diff --git a/client/tests/docsSite.test.ts b/client/tests/docsSite.test.ts
new file mode 100644
index 0000000..eaf9edf
--- /dev/null
+++ b/client/tests/docsSite.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from 'bun:test';
+import { buildSite } from '../docs-site/site';
+import { resolveHref, type LinkContext } from '../docs-site/links';
+import { PAGES, pageUrl } from '../docs-site/manifest';
+
+const EM_DASH = String.fromCharCode(0x2014);
+const site = buildSite();
+
+function html(path: string): string {
+ const content = site.get(path);
+ if (typeof content !== 'string') throw new Error(`missing ${path}`);
+ return content;
+}
+
+describe('docs site', () => {
+ test('the index and every navigation page exist', () => {
+ expect(site.has('docs/index.html')).toBe(true);
+ for (const page of PAGES) expect(site.has(`docs/${page.slug}/index.html`)).toBe(true);
+ });
+
+ test('no generated text contains an em dash', () => {
+ for (const [path, content] of site) {
+ if (typeof content === 'string') expect(content.includes(EM_DASH), path).toBe(false);
+ }
+ });
+
+ test('pages ship no script and keep their SEO tags', () => {
+ for (const page of PAGES) {
+ const doc = html(`docs/${page.slug}/index.html`);
+ expect(doc.includes('