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:
+256
-18
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user