diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index 8ebd69ef3..3f75e3e45 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -46,6 +46,9 @@ * --wait --key K [--poll 60] poll for the answer: exit 0 + ANSWER line, * exit 3 WAITING (run --wait again), exit 2 server gone. * --stop --key K kill a daemonized question. + * --update --key K --payload F deliver the next hand after a re-roll: the + * live page swaps to loading cards when the user re-rolls, and + * reloads into this new payload the moment it lands. * * node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0] */ @@ -119,6 +122,17 @@ if (hasFlag('stop')) { process.exit(0); } +if (hasFlag('update')) { + const key = arg('key'); + if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); } + JSON.parse(fs.readFileSync(payloadPath, 'utf8')); + try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); } + catch { console.error('serve-question: no live question server for that key'); process.exit(2); } + fs.copyFileSync(payloadPath, path.join(QUESTION_DIR, `${key}.next.json`)); + console.log('next round delivered; the page reloads itself'); + process.exit(0); +} + if (hasFlag('start')) { if (!payloadPath) { console.error('serve-question: --start needs --payload '); process.exit(1); } JSON.parse(fs.readFileSync(payloadPath, 'utf8')); @@ -145,27 +159,38 @@ if (hasFlag('start')) { let raw; if (payloadPath) raw = fs.readFileSync(payloadPath, 'utf8'); else raw = fs.readFileSync(0, 'utf8'); -const payload = JSON.parse(raw); -if (!payload || !Array.isArray(payload.options) || payload.options.length === 0) { - console.error('serve-question: payload needs an options array'); - process.exit(1); -} -// Local images are served through /img//; remote URLs pass through. -const localImages = []; -function imageSrc(value) { - if (!value) return null; - if (/^https?:\/\//.test(value)) return value; - const abs = path.resolve(value); - if (!fs.existsSync(abs)) return null; - localImages.push(abs); - return `/img/${localImages.length - 1}`; +// Round state is mutable: a re-roll keeps this server alive and --update +// swaps in the next hand, so payload, options, and the local-image table +// rebuild per round. +let payload; +let options; +let localImages = []; + +function loadRound(json) { + const parsed = JSON.parse(json); + if (!parsed || !Array.isArray(parsed.options) || parsed.options.length === 0) { + throw new Error('payload needs an options array'); + } + localImages = []; + const imageSrc = (value) => { + if (!value) return null; + if (/^https?:\/\//.test(value)) return value; + const abs = path.resolve(value); + if (!fs.existsSync(abs)) return null; + localImages.push(abs); + return `/img/${localImages.length - 1}`; + }; + payload = parsed; + options = parsed.options.map((option) => ({ + ...option, + heroSrc: imageSrc(option.hero), + boardSrc: imageSrc(option.board), + })); } -const options = payload.options.map((option) => ({ - ...option, - heroSrc: imageSrc(option.hero), - boardSrc: imageSrc(option.board), -})); +try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); } +const detachedKey = hasFlag('detached-serve') ? arg('key') : null; +const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.next.json`) : null; const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); @@ -205,7 +230,7 @@ function page() { ${esc(payload.title || 'impeccable ยท decision')} - + @@ -354,7 +385,7 @@ function page() { document.querySelectorAll('.card').forEach(card => { const hero = card.querySelector('.face.front .media img'); if (!hero) return; - card.addEventListener('mouseenter', () => { ambient.style.backgroundImage = 'url("' + hero.getAttribute('src') + '")'; ambient.style.opacity = '0.45'; }); + card.addEventListener('mouseenter', () => { ambient.style.backgroundImage = 'url("' + hero.getAttribute('src') + '")'; ambient.style.opacity = '1'; }); card.addEventListener('mouseleave', () => { ambient.style.opacity = '0'; }); }); @@ -374,16 +405,50 @@ function page() { const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); }; lightbox.addEventListener('click', closeLightbox); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); }); - document.getElementById('reroll')?.addEventListener('click', () => answer('reroll')); + document.getElementById('reroll')?.addEventListener('click', async () => { + await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) }); + const grid = document.querySelector('.grid'); + const cardsNow = [...grid.querySelectorAll('.card')]; + const g = grid.getBoundingClientRect(); + const cx = g.left + g.width / 2, cy = g.top + g.height / 2; + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { + cardsNow.forEach((card, i) => { + const r = card.getBoundingClientRect(); + card.style.transition = 'transform .5s cubic-bezier(.5,0,.75,0) ' + (i * 60) + 'ms, opacity .4s ease ' + (i * 60 + 120) + 'ms, filter .45s ease ' + (i * 60) + 'ms'; + card.style.transform = 'translate(' + (cx - (r.left + r.width / 2)) + 'px,' + (cy - (r.top + r.height / 2) + 14) + 'px) rotate(' + (i % 2 ? 6 : -5) + 'deg) scale(.9)'; + card.style.opacity = '0'; + card.style.filter = 'blur(8px)'; + }); + await new Promise(r => setTimeout(r, 700)); + } + grid.innerHTML = cardsNow.map(() => '
').join(''); + document.getElementById('reroll')?.setAttribute('disabled', ''); + const poll = setInterval(async () => { + try { + const status = await (await fetch('/next-status')).json(); + if (status.ready) { clearInterval(poll); location.reload(); } + } catch { /* server briefly busy */ } + }, 1200); + }); `; } const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/') { + const pending = nextFile(); + if (pending && fs.existsSync(pending)) { + try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ } + } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(page()); return; } + if (req.method === 'GET' && req.url === '/next-status') { + const pending = nextFile(); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) })); + return; + } const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)$/); if (imageMatch) { const abs = localImages[Number(imageMatch[1])]; @@ -402,14 +467,16 @@ const server = http.createServer((req, res) => { let parsed = {}; try { parsed = JSON.parse(body); } catch { /* empty steer */ } const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' }); - const detachedKey = hasFlag('detached-serve') ? arg('key') : null; + const isReroll = parsed.optionId === 'reroll'; if (detachedKey) { fs.mkdirSync(QUESTION_DIR, { recursive: true }); fs.writeFileSync(answerFile(detachedKey), answer + '\n'); } else { console.log(`ANSWER: ${answer}`); } - setTimeout(() => process.exit(0), 150); + // A re-roll in detached mode keeps the table open: the client shows a + // loading hand and reloads when --update delivers the next round. + if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150); }); return; }