Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
- name: Typecheck
working-directory: client
run: bun run typecheck
- name: Test
working-directory: client
run: bun test
- name: Build
working-directory: client
run: bun run build
Expand All @@ -43,3 +46,8 @@ jobs:
run: |
bun run golden 2>/dev/null > /tmp/golden.txt
diff /tmp/golden.txt ../contracts/deepmark-contract/tests/golden.txt
- name: Kit difficulty table is current
working-directory: client
run: |
bun run kits 2>/dev/null > /tmp/kits.ts
diff /tmp/kits.ts src/game/kitDifficulty.generated.ts
9 changes: 9 additions & 0 deletions client/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"golden": "bun run scripts/golden-vectors.ts",
"kits": "bun run scripts/kit-difficulty.ts",
"test": "bun test",
"perf": "bun run scripts/perf.ts"
},
"devDependencies": {
"@types/bun": "^1.4.2",
"puppeteer-core": "^25.12.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
Expand Down
2 changes: 1 addition & 1 deletion client/scripts/chain-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ async function clearOnce(choice: HeroChoice): Promise<{ heroId: number | undefin
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.slice(0, 10)} 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');
Expand Down
7 changes: 0 additions & 7 deletions client/scripts/golden-vectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,6 @@ function playOne(g: Rng): { line: string } | null {
}

function main(): void {
// The game persists progress through localStorage, which does not exist here.
const store = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
};

// Touch the floors so a layout change fails loudly instead of silently.
for (let n = 1; n <= 3; n++) buildFloor(n);

Expand Down
48 changes: 48 additions & 0 deletions client/scripts/kit-difficulty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Measures how often each starting kit clears all three floors and writes the
* table the creator reads its difficulty hints from.
*
* bun run kits > src/game/kitDifficulty.generated.ts
*
* Every fresh delver is rank 0, played by the fixed balanced policy over the
* same seeds for every kit. The output is deterministic, and CI regenerates it
* and fails on any difference, so a change to the simulation, the floors or the
* items cannot leave the hints saying something the game no longer does.
*/
import { CHARMS, WEAPONS } from '../src/game/items';
import { HERO_ORDER } from '../src/game/heroes';
import { mulberry32 } from '../src/game/rng';
import { newRun } from '../src/game/state';
import { playBalanced } from './policy';

const RUNS_PER_KIT = 2000;
const SEED_STREAM = 0x4b17_d1ff;

function clearPercent(base: number, weapon: number, charm: number, seeds: readonly number[]): number {
let cleared = 0;
for (const seed of seeds) {
const finished = playBalanced(newRun({ base, weapon, charm, rank: 0 }, seed));
if (finished.resultKind === 'extracted') cleared += 1;
}
return Math.round((cleared / seeds.length) * 100);
}

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 rows = table.map((weapons) => ` [${weapons.map((charms) => `[${charms.join(', ')}]`).join(', ')}],`);
console.log(
[
'// Generated by client/scripts/kit-difficulty.ts. Do not edit by hand: run `bun run kits`.',
`// Full clear rate in percent at rank 0, ${RUNS_PER_KIT} runs per kit, indexed [base][weapon][charm].`,
'export const KIT_CLEAR_PERCENT: readonly (readonly (readonly number[])[])[] = [',
...rows,
'];',
].join('\n')
);
}

main();
6 changes: 3 additions & 3 deletions client/scripts/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ function stepToward(state: RunState, goal: Point, avoid: boolean): ActionCode |
return null;
}

/** Plays a run to its end with the balanced policy and returns the state. */
export function playBalanced(state: RunState): RunState {
for (let step = 0; step < 800 && state.mode !== 'result'; step++) {
/** Plays a run with the balanced policy until it ends, or until it holds `maxActions` actions, and returns the state. */
export function playBalanced(state: RunState, maxActions: number = Infinity): RunState {
for (let step = 0; step < 800 && state.mode !== 'result' && state.actions.length < maxActions; step++) {
if (state.mode === 'combat') applyAction(state, ActionCode.Attack);
else if (state.mode === 'choice') applyAction(state, ActionCode.Descend);
else {
Expand Down
86 changes: 81 additions & 5 deletions client/src/game-app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ActionCode, applyAction, drainEvents, newRun, type GameEvent, type RunState } from './game/state';
import { loadProgress, type Progress } from './game/progress';
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';
Expand Down Expand Up @@ -48,7 +49,7 @@ 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, TopEntry } from './stellar/contract';
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;
Expand Down Expand Up @@ -163,7 +164,7 @@ async function refreshOnchain(): Promise<void> {
}

// Load the Stellar chunk when idle, then read the player's record. The title never waits for either.
warmChain(() => void refreshOnchain());
warmChain(() => void refreshOnchain().then(resumeOpenRun));

function onchainSnapshot(): OnchainSnapshot | null {
if (!onchainRecord) return null;
Expand Down Expand Up @@ -309,6 +310,7 @@ async function beginRun(plan: DelverPlan): Promise<void> {
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)));
Expand Down Expand Up @@ -382,11 +384,14 @@ async function verifyOnChain(finished: RunState): Promise<void> {
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;
Expand Down Expand Up @@ -424,6 +429,7 @@ function endExhaustedRun(state: RunState): void {
state.relicsThisRun = 0;
state.scoreThisRun = 0;
practice = true;
clearSavedRun();
chainStatus = 'This run grew too long to be verified.';
}

Expand All @@ -434,6 +440,74 @@ const DIRS: Partial<Record<Action, [number, number]>> = {
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<ResumePlan, { kind: 'discard' }>, 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);
}

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<void> {
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 handleEvent(event: GameEvent): void {
switch (event.type) {
case 'enemyHit': {
Expand Down Expand Up @@ -522,7 +596,7 @@ function handleEvent(event: GameEvent): void {
case 'extracted':
stopAmbience();
sfxExtracted();
progress = loadProgress();
if (run) settleLocalProgress(run);
runId += 1;
if (run) {
const heroId = HERO_ORDER[run.loadout.base] ?? 'lantern';
Expand All @@ -535,7 +609,7 @@ function handleEvent(event: GameEvent): void {
sfxDead();
triggerShake(250, 10);
canvas.classList.add('board-sunk');
progress = loadProgress();
if (run) settleLocalProgress(run);
runId += 1;
if (run && !practice) void verifyOnChain(run);
break;
Expand Down Expand Up @@ -719,6 +793,7 @@ onAction((action) => {
return;
}

const actionsBefore = run.actions.length;
if (run.mode === 'combat') {
if (action === 'confirm') applyAction(run, ActionCode.Attack);
if (action === 'cancel') applyAction(run, ActionCode.Retreat);
Expand All @@ -738,6 +813,7 @@ onAction((action) => {
}

if (run.exhausted) endExhaustedRun(run);
if (!practice && run.actions.length !== actionsBefore) saveRun(run);
for (const event of drainEvents(run)) handleEvent(event);
});

Expand Down
3 changes: 2 additions & 1 deletion client/src/game/draft.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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';

/** A delver being built in the creator, kept in this browser until a run starts. */
Expand All @@ -20,7 +21,7 @@ export function randomName(): string {

export function defaultDraft(): Draft {
// The kit that clears most often in the balance simulator, so a first descent is a fair one.
return { look: { name: randomName(), emblem: 0, cloak: 0 }, base: 1, weapon: 1, charm: 2 };
return { look: { name: randomName(), emblem: 0, cloak: 0 }, ...RECOMMENDED_KIT };
}

function inRange(value: unknown, count: number): value is number {
Expand Down
2 changes: 1 addition & 1 deletion client/src/game/enemies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const ENEMIES: Record<EnemyKind, EnemyArchetype> = {
name: 'Warden',
maxHp: 6,
dmgMin: 1,
dmgMax: 3,
dmgMax: 2,
color: '#ef5350',
size: 40,
shape: 'square',
Expand Down
2 changes: 1 addition & 1 deletion client/src/game/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ 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: 1, name: 'Reaver Hook', icon: 'reaver-hook.webp', blurb: 'Wild swings that can end a fight early.', hp: -2, dmgMin: 0, dmgMax: 2 },
{ 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 },
];

Expand Down
6 changes: 6 additions & 0 deletions client/src/game/kitDifficulty.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Generated by client/scripts/kit-difficulty.ts. Do not edit by hand: run `bun run kits`.
// Full clear rate in percent at rank 0, 2000 runs per kit, indexed [base][weapon][charm].
export const KIT_CLEAR_PERCENT: readonly (readonly (readonly number[])[])[] = [
[[43, 32, 44], [43, 32, 43], [31, 47, 29]],
[[47, 33, 50], [46, 28, 47], [44, 44, 42]],
];
Loading
Loading