fix(ux): 14 cross-platform UX/UI fixes from 500-game audit

Web client (game.js):
- Restart game timer after undo exits auto-complete sequence
- Pause timer while browser tab is hidden (visibilitychange)
- Validate URL seed — NaN / negative falls back to randomSeed()
- Guard onBoardClick/onBoardDblClick during win (snap.is_won)
- Delay win overlay 320 ms so last card CSS transition finishes
- Force reflow in flashIllegal() to restart shake on rapid re-trigger

Android (safe_area.rs):
- Preserve last-known insets on app resume instead of zeroing them;
  eliminates double layout flash on every foreground cycle

All clients — Bevy engine:
- Radial menu: clamp icon anchors to viewport bounds so icons are
  never placed off-screen on narrow phones
- Auto-complete: deactivate state.active when is_auto_completable
  goes false (undo mid-sequence) to stop perpetual background retry
- Touch selection: gate highlight rebuild on is_changed() — was
  despawning/respawning entities every frame unnecessarily
- Input: fire "Tap a pile to move" InfoToast on first tap in
  TapToSelect mode; document cursor_world 1:1 viewport invariant
- Drag threshold: raise desktop from 4 → 6 px to prevent accidental
  drags from cursor jitter on HiDPI displays

Desktop / Android (solitaire_app):
- Call cleanup_orphaned_tmp_files() at startup to remove .tmp files
  left by crashes between atomic write and rename

Design clarification (klondike_adapter.rs):
- Doc comment: Draw-1 recycling is penalty-only by design (never
  blocked) to avoid creating unwinnable positions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-01 21:23:52 -07:00
parent 20e5222148
commit 64f975ed6d
9 changed files with 571 additions and 216 deletions
+256 -18
View File
@@ -176,9 +176,12 @@ async function bootstrap() {
if (saved) {
showResumeDialog(saved);
} else {
const params = new URLSearchParams(window.location.search);
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
drawThree = params.has("draw3");
const params = new URLSearchParams(window.location.search);
const rawSeed = Number(params.get("seed"));
const urlSeed = params.has("seed") && Number.isFinite(rawSeed) && rawSeed > 0
? Math.floor(rawSeed)
: randomSeed();
drawThree = params.has("draw3");
chkDraw3.checked = drawThree;
startGame(urlSeed);
}
@@ -393,8 +396,16 @@ function render(s) {
stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (noMovesBanner) noMovesBanner.classList.add("hidden");
showWin(s);
// Delay slightly so the last card's CSS transition finishes before
// the win overlay covers the board. Card transitions are ~260 ms.
setTimeout(() => showWin(s), 320);
} else {
// If the player undid out of auto-complete, restart the timer —
// stopTimer() was called when auto-complete began, but no code path
// before here restarts it after an undo.
if (!s.is_auto_completable && !timerInterval) {
startTimer();
}
saveState();
const noMoves = !s.has_moves && !s.is_auto_completable;
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
@@ -429,20 +440,34 @@ function showWin(s) {
submitReplay(s);
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token) return;
const payload = {
schema_version: 1,
function buildReplayPayload(s) {
if (!game || !s) return null;
let moves;
try {
moves = game.replay_moves();
if (!Array.isArray(moves) || moves.length === 0) return null;
} catch (e) {
console.warn("fs: replay export failed", e);
return null;
}
return {
schema_version: 2,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "Classic",
time_seconds: elapsedSecs,
time_seconds: Math.max(1, elapsedSecs),
final_score: s.score,
move_count: s.move_count,
recorded_at: new Date().toISOString().slice(0, 10),
moves: [],
moves,
win_move_index: moves.length - 1,
};
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token || !game) return;
const payload = buildReplayPayload(s);
if (!payload) return;
try {
await fetch('/api/replays', {
method: 'POST',
@@ -467,7 +492,12 @@ function flashIllegal(cardIds) {
for (const id of cardIds) {
const el = cardEls.get(id);
if (!el) continue;
// Store current translate so the shake keyframe can reference it.
// Remove any in-progress shake before restarting. Reading offsetWidth
// forces a synchronous layout flush so the browser sees the removal
// before we re-add the class, restarting the animation from frame 0.
el.classList.remove("illegal");
el.style.removeProperty("--card-tx");
void el.offsetWidth; // flush layout — do not remove
el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
el.classList.add("illegal");
el.addEventListener("animationend", () => {
@@ -496,11 +526,34 @@ function attachHandlers() {
syncThemeButton();
if (game) render(game.state());
});
const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); };
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.key === "z" || e.key === "Z") doUndo();
if (e.key === "n" || e.key === "N") startGame(randomSeed());
const tag = e.target?.tagName;
if (e.target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "z" || e.key === "Z" || e.key === "u" || e.key === "U") {
e.preventDefault();
doUndo();
return;
}
if (e.key === "n" || e.key === "N") {
startGame(randomSeed());
return;
}
if (!e.repeat && (e.code === "Space" || e.key === " ")) {
e.preventDefault();
doDraw();
}
});
// Pause the game timer while the tab is hidden so background time doesn't
// inflate the player's recorded game duration.
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopTimer();
} else if (snap && !snap.is_won && !snap.is_auto_completable) {
startTimer();
}
});
board.addEventListener("pointerdown", onPointerDown);
@@ -706,7 +759,7 @@ function onPointerCancel() {
// ── Click / dblclick ──────────────────────────────────────────────────────────
function onBoardClick(e) {
if (drag) return;
if (drag || snap?.is_won) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const stock = PILE_ORIGIN.stock;
if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) {
@@ -741,7 +794,7 @@ function smartMove(pileName, fromIndex) {
}
function onBoardDblClick(e) {
if (drag) return;
if (drag || snap?.is_won) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const hit = hitTestCard(bx, by);
if (!hit || !hit.card.face_up) return;
@@ -782,6 +835,191 @@ async function loadAvatar() {
} catch { /* not signed in — avatar stays hidden */ }
}
function debugStateKey(state) {
if (!state) return "missing";
if (Array.isArray(state.stock) || Array.isArray(state.tableaus)) {
const out = [];
const push = cards => {
for (const c of cards || []) out.push(`${c.id}:${c.face_up ? 1 : 0}`);
out.push("|");
};
push(state.stock);
push(state.waste);
for (const pile of state.foundations || []) push(pile);
for (const pile of state.tableaus || []) push(pile);
return out.join("");
}
return JSON.stringify(state);
}
function orderBaselineDebugMoves(legalMoves) {
const foundationSingles = [];
const moveKind = [];
const rest = [];
for (let i = 0; i < legalMoves.length; i++) {
const move = legalMoves[i];
if (
move?.kind === "move" &&
typeof move.to === "string" &&
move.to.startsWith("foundation-") &&
move.count === 1
) {
foundationSingles.push(i);
} else if (move?.kind === "move") {
moveKind.push(i);
} else {
rest.push(i);
}
}
return [...foundationSingles, ...moveKind, ...rest];
}
function runDebugAutoplay(options = {}) {
if (!game) return { ok: false, reason: "game_not_ready", step: 0 };
const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0 ? options.maxSteps : 220;
const maxVisitsPerState =
Number.isInteger(options.maxVisitsPerState) && options.maxVisitsPerState > 0
? options.maxVisitsPerState
: 2;
const policy = options.policy === "baseline" ? "baseline" : "loop_aware";
const seen = new Map();
function simulatedVisitCount(legalMoveIndex) {
let saved = null;
try {
saved = game.serialize();
} catch {
return null;
}
if (typeof saved !== "string" || saved.length === 0) return null;
const applied = game.debug_apply_legal_move(legalMoveIndex);
if (!applied?.ok) {
try { game = SolitaireGame.from_saved(saved); } catch {}
return null;
}
const nextKey = debugStateKey(applied.snapshot);
try {
game = SolitaireGame.from_saved(saved);
} catch {
return null;
}
return seen.get(nextKey) || 0;
}
for (let step = 0; step < maxSteps; step++) {
const snap = game.debug_snapshot();
if (!snap?.state || !snap?.invariants) {
return { ok: false, reason: "missing_snapshot", step };
}
if (!snap.invariants.state_ok) {
return { ok: false, reason: "invariant_failed", step, snapshot: snap };
}
if (snap.state.is_won) {
return { ok: true, terminal: "won", step, snapshot: snap };
}
const key = debugStateKey(snap.state);
const visits = (seen.get(key) || 0) + 1;
seen.set(key, visits);
if (visits > maxVisitsPerState) {
return { ok: true, terminal: "cycle", step, snapshot: snap };
}
const legalMoves = game.debug_legal_moves();
if (!Array.isArray(legalMoves) || legalMoves.length === 0) {
return { ok: true, terminal: "no_moves", step, snapshot: snap };
}
const ordered = orderBaselineDebugMoves(legalMoves);
let idx = ordered[0];
if (policy === "loop_aware" && ordered.length > 1) {
let bestIdx = ordered[0];
let bestVisitCount = Number.MAX_SAFE_INTEGER;
for (const candidate of ordered) {
const visitCount = simulatedVisitCount(candidate);
if (visitCount === null) continue;
if (visitCount < bestVisitCount) {
bestVisitCount = visitCount;
bestIdx = candidate;
if (visitCount === 0) break;
}
}
idx = bestIdx;
}
const result = game.debug_apply_legal_move(idx);
if (!result?.ok) {
return {
ok: false,
reason: "apply_failed",
step,
idx,
error: result?.error ?? "unknown_error",
};
}
if (result.snapshot) render(result.snapshot);
}
const finalSnap = game.debug_snapshot();
return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget", snapshot: finalSnap };
}
// ── Debug API (engine-first automation surface) ───────────────────────────────
// Playwright and other automation harnesses use this object instead of pixel
// analysis or hardcoded coordinates. Every operation delegates to the Rust
// rules engine exported by `solitaire_wasm`.
window.__FERROUS_DEBUG__ = {
seed() {
return game ? Math.round(game.seed()) : null;
},
state() {
return game ? game.state() : null;
},
legalMoves() {
return game ? game.debug_legal_moves() : [];
},
moveHistory() {
return game ? game.debug_move_history() : [];
},
snapshot() {
return game ? game.debug_snapshot() : null;
},
applyLegalMove(index) {
if (!game) return { ok: false, error: "game_not_ready" };
const result = game.debug_apply_legal_move(index);
if (result?.ok && result.snapshot) render(result.snapshot);
return result;
},
applyMove(move) {
if (!game) return { ok: false, error: "game_not_ready" };
const payload = typeof move === "string" ? move : JSON.stringify(move);
const result = game.debug_apply_move_json(payload);
if (result?.ok && result.snapshot) render(result.snapshot);
return result;
},
failureReport() {
if (!game) return null;
const debug = game.debug_snapshot();
return {
seed: Math.round(game.seed()),
moveHistory: debug?.move_history ?? [],
currentState: debug?.state ?? game.state(),
stateJson: debug?.state_json ?? null,
legalMoves: debug?.legal_moves ?? [],
invariants: debug?.invariants ?? null,
};
},
replayPayload() {
if (!game) return null;
return buildReplayPayload(snap ?? game.state());
},
runAutoplay(options) {
return runDebugAutoplay(options);
},
};
// ── Start ─────────────────────────────────────────────────────────────────────
bootstrap().catch(console.error);
loadAvatar();