Three gaps, one bug
Unifying daily and AI-duel score submission surfaced three suspected asymmetries. Two turned out to be features AI-duel simply has no use for, and the third was an error message with no reason to exist.
Daily and AI-duel each posted a finished game’s score by hand, a fetch call wrapped in the same forty-line try/catch/isSubmitting/scoreSubmitted dance, copied into both pages and tested in neither. The same 2026-07-18 architecture review that found the word panel’s ownership chain duplicated flagged this one too, marked speculative from the start: the two payloads genuinely differ in shape and size, so unifying might have added indirection for no real gain.
Digging in for real turned the speculative flag into three suspected gaps, and two of them dissolved on inspection. AI-duel never showed a rank after submitting, but DuelRecap.svelte has no rank slot to fill, so wiring one in would have been a feature addition, not a fix for anything missing from the call itself. Daily caches typed initials to sessionStorage so a repeat player doesn’t retype them; AI-duel has no such cache, but that’s a “remember what I typed” convenience, separable from how the score gets posted. The third looked most like a real gap: daily calls markDailyResultSubmitted to keep its postponed-submission view in sync after a late-arriving response. AI-duel calls nothing like it, but AI-duel also auto-starts a fresh game on mount every time and has no revisit view at all, so there is no state left to fall out of sync. Not a missing piece of bookkeeping, just a feature daily has that AI-duel was never built to need.
The one asymmetry that survived was the actual submission behavior. Daily already stays silent on any failure except a 422, the one status a player can act on by retyping initials, a policy an existing code comment traces back to an earlier decision that retrying anything else isn’t something a new name fixes. AI-duel showed a generic network-error message on every failure instead, with no comment or issue anywhere explaining why, reading like whatever the first draft happened to do rather than a considered choice. Unifying the two meant AI-duel adopting daily’s policy, not the other way around, and dropping its error copy for that path entirely.
export type ScoreSubmitResult =
| { ok: true; rank?: number }
| { ok: false; reason: 'blocked' }
| { ok: false; reason: 'other' };
export async function postScoreSubmission(
endpoint: string,
payload: object
): Promise<ScoreSubmitResult> {
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
return { ok: false, reason: res.status === 422 ? 'blocked' : 'other' };
}
const body = (await res.json().catch(() => null)) as { playerRank?: number } | null;
const rank = typeof body?.playerRank === 'number' ? body.playerRank : undefined;
return { ok: true, rank };
} catch {
return { ok: false, reason: 'other' };
}
}
The shape question resolved in favor of two thin wrappers over one shared internal helper, not a single generic function taking both payload shapes. Daily’s params run to a dozen fields; AI-duel’s are keyed by a single game id with a handful of fields alongside it, a genuinely smaller shape rather than a subset of the same one. Forcing both through one union-typed signature would have meant every call site reasoning about fields that don’t apply to it, so dailyGameClient.ts and duelGameClient.ts each got their own typed submitScore(), both built on the shared helper above. Each page still owns its own isSubmitting and scoreSubmitted state and its own success handling exactly as before; only the fetch-and-classify plumbing moved. Ten new tests cover it, the shared helper directly, and each mode’s wrapper against its own payload and its own 422-versus-everything-else split, none of it under test before today.
Nothing about this touched a server route, on either mode’s side, and PvP’s leaderboard stays exactly as out of scope as the original issue said it would, server-written at game end and no client adapter’s business. The duplication is gone; the two pages’ actual differences, the ones that were real, are still right there in the type signatures where anyone reading the code can see them.