Skip to content
204 changes: 162 additions & 42 deletions script.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,17 @@
let movesSinceSolvabilityCheck = 0;
let riftState = {
active:false,
sequenceRunning:false,
nodes:[],
hasTriggered:false,
cooldownUntil:0,
copyKey:'pattern'
};
let riftSequenceRunning = false;
let lastFocusedBeforeRiftModal = null;
const riftExtensionHooks = {
onTrigger:[],
onResolve:[]
};
const riftCopySets = {
pattern:{
title:'A seam in the pattern',
Expand Down Expand Up @@ -270,6 +275,13 @@

function setStatus(msg){ statusEl.textContent=msg; }

function notifyRiftHook(name, payload){
const handlers = riftExtensionHooks[name] || [];
handlers.forEach(handler => {
try { handler(payload); } catch { /* no-op */ }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Swallowing errors from extension handlers silently with a /* no-op */ can make debugging extensions very difficult. It's better to at least log the error to the console so that extension developers are aware of issues in their code.

Suggested change
try { handler(payload); } catch { /* no-op */ }
try { handler(payload); } catch (e) { console.error('Rift extension hook failed:', e); }

});
}

function setInteractionLocked(locked){
interactionLocked=locked;
boardShellEl.classList.toggle('rift-locked',locked);
Expand Down Expand Up @@ -308,11 +320,44 @@
boardWasSolvable=true;
}

function serializeRiftState(){
return {
active:riftState.active,
nodes:riftState.nodes.slice(0,3),
hasTriggered:riftState.hasTriggered,
cooldownUntil:riftState.cooldownUntil,
copyKey:riftState.copyKey
};
}

function deserializeRiftState(savedRiftState={}){
const isRiftActive = !!savedRiftState.active;
return {
active:isRiftActive,
nodes:isRiftActive ? (savedRiftState.nodes||[]).slice(0,3) : [],
hasTriggered:!!savedRiftState.hasTriggered,
cooldownUntil:Number(savedRiftState.cooldownUntil)||0,
copyKey:savedRiftState.copyKey||'pattern'
};
}

function resetRiftState(){
riftState = { active:false, nodes:[], hasTriggered:false, cooldownUntil:0, copyKey:'pattern' };
riftSequenceRunning = false;
}

function applyPostMovePipeline({ origin='player-move', riftOptions={}, shouldEvaluateRift=true, shouldCaptureSolvable=false }={}){
render();
saveGame();
if(shouldCaptureSolvable) captureLastSolvableSnapshot();
if(shouldEvaluateRift) scheduleRiftEvaluation(origin, riftOptions);
}

function clearRiftVisualState(){
statusSequenceId++;
if(riftEvalTimer){ clearTimeout(riftEvalTimer); riftEvalTimer=null; }
riftState.active=false;
riftState.sequenceRunning=false;
riftSequenceRunning=false;
riftState.nodes=[];
movesSinceSolvabilityCheck=0;
boardShellEl.classList.remove('rift-active','rift-glitch');
Expand Down Expand Up @@ -352,54 +397,66 @@
const copy=riftCopySets[riftState.copyKey]||riftCopySets.pattern;
riftBodyEl.textContent=copy.fragment;
riftTitleEl.textContent=copy.title;
lastFocusedBeforeRiftModal = document.activeElement;
riftModal.hidden=false;
riftReturnBtn.focus();
}

async function triggerRiftEvent(){
if(riftState.active || riftState.sequenceRunning) return;
riftState.sequenceRunning=true;
if(riftState.active || riftSequenceRunning) return;
riftSequenceRunning=true;
riftState.hasTriggered=true;
riftState.cooldownUntil=Date.now()+RIFT_COOLDOWN_MS;
setInteractionLocked(true);
boardShellEl.classList.add('rift-active','rift-glitch');
boardShellEl.classList.add('rift-active');
if(!reducedMotion.matches) boardShellEl.classList.add('rift-glitch');
statusEl.classList.add('rift-status');
vibrate([15,55,15]);
render();
const copy=riftCopySets[riftState.copyKey]||riftCopySets.pattern;
const complete=await runStatusSequence(copy.lines);
boardShellEl.classList.remove('rift-glitch');
if(!complete){
riftState.sequenceRunning=false;
riftSequenceRunning=false;
return;
}
riftState.nodes=getRiftNodes();
riftState.active=true;
riftState.sequenceRunning=false;
riftSequenceRunning=false;
setInteractionLocked(false);
render();
setStatus('Rift node found. Tap the marked cell.');
saveGame();
notifyRiftHook('onTrigger', { rift:serializeRiftState(), snapshot:snapshotCurrentState() });
}

function evaluateRiftTrigger(origin='system', options={}){
if(origin!=='player-move') return;
if(riftState.active||riftState.sequenceRunning) return;
if(Date.now()<riftState.cooldownUntil) return;
function shouldTriggerRift(origin='system', options={}, solvable){
if(origin!=='player-move') return false;
if(riftState.active||riftSequenceRunning) return false;
if(riftState.hasTriggered) return false;
if(Date.now()<riftState.cooldownUntil) return false;
// Explicit policy: evaluate immediately after conflict-introducing moves,
// otherwise only every N non-conflicting moves to reduce solver pressure.
if(!options.force){
if(options.conflictIntroduced){
movesSinceSolvabilityCheck=0;
} else {
movesSinceSolvabilityCheck++;
if(movesSinceSolvabilityCheck<RIFT_NON_CONFLICT_CHECK_INTERVAL) return;
if(movesSinceSolvabilityCheck<RIFT_NON_CONFLICT_CHECK_INTERVAL) return false;
movesSinceSolvabilityCheck=0;
}
}
const solvable=hasAnySolution(grid);
if(solvable){
markCurrentStateAsSolvable();
return;
return false;
}
if(boardWasSolvable){
return boardWasSolvable;
}

function evaluateRiftTrigger(origin='system', options={}){
if(origin!=='player-move') return;
const solvable=hasAnySolution(grid);
if(shouldTriggerRift(origin, options, solvable)){

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move solver call behind rift trigger gating

evaluateRiftTrigger computes hasAnySolution(grid) before applying the cooldown/active/debounce policy in shouldTriggerRift, so the expensive backtracking solver now runs on every scheduled player move (including non-conflicting moves that should be skipped). This is a regression from the prior flow where these gates short-circuited first, and it can cause noticeable lag/battery drain on harder boards and slower devices.

Useful? React with 👍 / 👎.

boardWasSolvable=false;
triggerRiftEvent();
}
Expand All @@ -414,17 +471,9 @@
}

function restoreRiftStateFromSave(data){
const savedRiftState=data.riftState||{};
const isRiftActive=!!savedRiftState.active;
riftState={
active:isRiftActive,
sequenceRunning:false,
nodes:isRiftActive?(savedRiftState.nodes||[]).slice(0,3):[],
hasTriggered:!!savedRiftState.hasTriggered,
cooldownUntil:Number(savedRiftState.cooldownUntil)||0,
copyKey:savedRiftState.copyKey||'pattern'
};
if(isRiftActive){
riftState=deserializeRiftState(data.riftState||{});
riftSequenceRunning=false;
if(riftState.active){
boardShellEl.classList.add('rift-active');
statusEl.classList.add('rift-status');
}
Expand Down Expand Up @@ -465,7 +514,7 @@
}

function updateStatus(){
if(riftState.sequenceRunning) return;
if(riftSequenceRunning) return;
if(riftState.active){ setStatus('Rift node found. Tap the marked cell.'); return; }
if(!selected){ setStatus('Tap a cell to select it, then tap a number.'); return; }
const {r,c}=selected;
Expand Down Expand Up @@ -555,13 +604,7 @@
selected, elapsed, notesMode, autoCleanup,
difficulty:difficultyEl.value,
lastSolvableSnapshot:encodedLastSolvable,
riftState:{
active:riftState.active,
nodes:riftState.nodes,
hasTriggered:riftState.hasTriggered,
cooldownUntil:riftState.cooldownUntil,
copyKey:riftState.copyKey
}
riftState:serializeRiftState()
};
try{
localStorage.setItem(STORAGE_KEY,JSON.stringify(payload));
Expand Down Expand Up @@ -689,7 +732,7 @@
selected=null; elapsed=0; history=[]; future=[];
boardShellEl.classList.remove('victory-glow');
clearRiftVisualState();
riftState={active:false,sequenceRunning:false,nodes:[],hasTriggered:false,cooldownUntil:0,copyKey:'pattern'};
resetRiftState();
movesSinceSolvabilityCheck=0;
captureLastSolvableSnapshot();
render(); startTimer(); saveGame(); hideSplash();
Expand All @@ -706,13 +749,13 @@
if(grid[r][c]!==0){ history.pop(); setStatus('Clear the number first before adding notes.'); return; }
if(notes[r][c].has(n)) notes[r][c].delete(n); else notes[r][c].add(n);
vibrate(10);
render(); saveGame(); return;
applyPostMovePipeline({ shouldEvaluateRift:false }); return;
}
grid[r][c]=n;
notes[r][c].clear();
autoCleanNotesAround(r,c,n);
const introducedConflict=hasConflict(r,c);
render(); saveGame();
applyPostMovePipeline({ riftOptions:{conflictIntroduced:introducedConflict} });
if(introducedConflict){
vibrate([10,50,10]);
const cellEl=boardEl.querySelector(`[data-r="${r}"][data-c="${c}"]`);
Expand All @@ -721,7 +764,6 @@
vibrate(10);
}
if(isSolved()){ setStatus('Solved. Nice work.'); celebrate(); }
scheduleRiftEvaluation('player-move',{conflictIntroduced:introducedConflict});
}

function clearSelected(){
Expand All @@ -731,9 +773,8 @@
if(startingGrid[r][c]!==0){ setStatus('Starting number — this cell cannot be changed.'); return; }
pushHistory();
grid[r][c]=0; notes[r][c].clear();
render(); saveGame();
applyPostMovePipeline({ riftOptions:{conflictIntroduced:false} });
setStatus('Cell cleared.');
scheduleRiftEvaluation('player-move',{conflictIntroduced:false});
}

function jumpToNextEmpty(){
Expand Down Expand Up @@ -804,10 +845,9 @@
const ok=solve();
grid=b;
notes=Array.from({length:GRID_SIZE},()=>Array.from({length:GRID_SIZE},()=>new Set()));
render(); saveGame();
applyPostMovePipeline({ shouldEvaluateRift:false, shouldCaptureSolvable:true });
if(ok) setStatus('Solved.');
else setStatus('Could not solve — fix the conflicts first.');
captureLastSolvableSnapshot();
}

function moveSelection(dr, dc){
Expand All @@ -831,6 +871,24 @@
clearRiftVisualState();
setStatus(returnMessage);
saveGame();
notifyRiftHook('onResolve', { message:returnMessage, rift:serializeRiftState(), snapshot:snapshotCurrentState() });
if(lastFocusedBeforeRiftModal && typeof lastFocusedBeforeRiftModal.focus === 'function'){
lastFocusedBeforeRiftModal.focus();
}
lastFocusedBeforeRiftModal = null;
}

function registerRiftExtension(extension={}){
const dispose = [];
if(typeof extension.onTrigger === 'function'){
riftExtensionHooks.onTrigger.push(extension.onTrigger);
dispose.push(()=>{ riftExtensionHooks.onTrigger = riftExtensionHooks.onTrigger.filter(fn=>fn!==extension.onTrigger); });
}
if(typeof extension.onResolve === 'function'){
riftExtensionHooks.onResolve.push(extension.onResolve);
dispose.push(()=>{ riftExtensionHooks.onResolve = riftExtensionHooks.onResolve.filter(fn=>fn!==extension.onResolve); });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for registering and creating disposal functions for onTrigger and onResolve hooks is duplicated. This can be refactored to be more DRY by iterating over the hook names, which would make it easier to maintain and add new hooks in the future.

Suggested change
if(typeof extension.onTrigger === 'function'){
riftExtensionHooks.onTrigger.push(extension.onTrigger);
dispose.push(()=>{ riftExtensionHooks.onTrigger = riftExtensionHooks.onTrigger.filter(fn=>fn!==extension.onTrigger); });
}
if(typeof extension.onResolve === 'function'){
riftExtensionHooks.onResolve.push(extension.onResolve);
dispose.push(()=>{ riftExtensionHooks.onResolve = riftExtensionHooks.onResolve.filter(fn=>fn!==extension.onResolve); });
}
['onTrigger', 'onResolve'].forEach(hookName => {
const handler = extension[hookName];
if (typeof handler === 'function') {
riftExtensionHooks[hookName].push(handler);
dispose.push(() => {
riftExtensionHooks[hookName] = riftExtensionHooks[hookName].filter(fn => fn !== handler);
});
}
});

return () => dispose.forEach(fn=>fn());
}

// ── Build digit pad ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -947,8 +1005,70 @@

// ── Boot ──────────────────────────────────────────────────────────────────

function installTestHooks(){
const isLocalHost = ['localhost','127.0.0.1'].includes(window.location.hostname);
const explicitFlag = new URLSearchParams(window.location.search).get('testHooks') === '1';
if(!isLocalHost && !explicitFlag) return;
window.__shandokuTest = {
getStateSummary(){
return {
selected:selected?{...selected}:null,
elapsed,
notesMode,
autoCleanup,
errors:countErrors(),
filled:countFilled(),
boardWasSolvable,
hasLastSolvableSnapshot:!!lastSolvableSnapshot
};
},
setBoardState(nextState={}){
if(!nextState.grid || !Array.isArray(nextState.grid) || nextState.grid.length!==GRID_SIZE){
throw new Error('setBoardState requires a 9x9 grid.');
}
const nextGrid = nextState.grid.map(row => row.slice());
const nextStartingGrid = nextState.startingGrid ? nextState.startingGrid.map(row => row.slice()) : nextGrid.map(row => row.slice());
const nextNotes = nextState.notes
? nextState.notes.map(row => row.map(arr => new Set(Array.isArray(arr) ? arr : [])))
: Array.from({length:GRID_SIZE},()=>Array.from({length:GRID_SIZE},()=>new Set()));
grid=nextGrid;
startingGrid=nextStartingGrid;
notes=nextNotes;
selected=nextState.selected ? { ...nextState.selected } : null;
history=[];
future=[];
clearRiftVisualState();
resetRiftState();
boardWasSolvable=hasAnySolution(grid);
if(boardWasSolvable) captureLastSolvableSnapshot();
render();
saveGame();
},
forceEvaluateRift(){
evaluateRiftTrigger('player-move',{ force:true, conflictIntroduced:true });
},
getRiftState(){
return {
...serializeRiftState(),
sequenceRunning:riftSequenceRunning,
interactionLocked
};
},
restoreLastSolvable(){
restoreLastSolvableState();
},
clearSave(){
localStorage.removeItem(STORAGE_KEY);
}
};
window.__shandokuInternal = {
registerRiftExtension
};
}

applyTheme(localStorage.getItem(THEME_KEY)||'dark');
buildDigitPad();
installTestHooks();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini do we even need these testHooks() now that we've integrated playwright via the main branch?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great question, ssfinney! While Playwright provides excellent end-to-end testing capabilities by simulating user interactions, the installTestHooks() function serves a different, complementary purpose that remains valuable.

These test hooks expose internal game state and control functions (like setBoardState, getStateSummary, forceEvaluateRift, and registerRiftExtension) directly to the window object under a __shandokuTest namespace. This allows Playwright tests (or any other in-browser testing framework) to:

  • Efficiently set up complex test scenarios: Instead of simulating many clicks to reach a specific board state, tests can directly call setBoardState.
  • Assert internal application state: It's often easier and more reliable to check the actual game state (e.g., countErrors(), boardWasSolvable) directly via getStateSummary rather than inferring it solely from UI elements.
  • Trigger specific events: Functions like forceEvaluateRift allow testing specific logic paths that might be hard to reliably trigger through UI interactions alone.
  • Observe internal events: registerRiftExtension allows tests to subscribe to and verify rift lifecycle events.

In essence, these hooks provide a more direct, less brittle, and often faster way to interact with the application's logic for testing purposes, complementing the UI-driven tests provided by Playwright. They are crucial for writing robust and maintainable tests that cover both UI and underlying game mechanics.


// Defer game init until after the first paint so the splash animates.
requestAnimationFrame(()=>requestAnimationFrame(()=>{
Expand Down
Loading