Convert Twitch controls to video overlay
This commit is contained in:
@@ -24,6 +24,10 @@ npm run dev
|
||||
|
||||
Open `http://localhost:3000`. Expand **Local viewer login**, then choose
|
||||
**Spawn & bind** to exercise the complete local controller loop without Twitch.
|
||||
The production Twitch viewer is the transparent Video Overlay at
|
||||
`http://localhost:3000/extension`; use
|
||||
`http://localhost:3000/extension?dev=1&debug=1` to exercise its shared controls
|
||||
and view alignment boundaries locally.
|
||||
|
||||
Quality gates:
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ export const server=createServer(async(req,res)=>{
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
|
||||
if(req.method==='POST'&&url.pathname==='/api/dev/redemption'&&!config.twitchEnabled){const msg=await twitch.normalizeRedemption(await body(req));if(!msg)return json(res,400,{error:'INVALID_REDEMPTION'});const result=game.resurrect(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
|
||||
if(req.method==='GET'&&(url.pathname==='/'||url.pathname==='/extension')){const html=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)}
|
||||
if(req.method==='GET'&&url.pathname==='/'){const page=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)}
|
||||
if(req.method==='GET'&&url.pathname==='/extension'){const page=(await readFile(join(publicDir,'index.html'),'utf8')).replace('<html lang="en">','<html lang="en" class="extension-mode">');res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(page)}
|
||||
if(req.method==='GET'&&url.pathname==='/privacy.html'){const html=await readFile(join(publicDir,'privacy.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)}
|
||||
if(req.method==='GET'&&url.pathname==='/app.js'){const js=await readFile(join(publicDir,'app.js'));res.writeHead(200,{'content-type':'text/javascript; charset=utf-8','cache-control':'no-store'});return res.end(js)}
|
||||
if(req.method==='GET'&&url.pathname==='/styles.css'){const css=await readFile(join(publicDir,'styles.css'));res.writeHead(200,{'content-type':'text/css; charset=utf-8','cache-control':'no-store'});return res.end(css)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensionIdentityPending=false
|
||||
const extensionMode=location.pathname==='/extension';document.body.classList.toggle('extension-mode',extensionMode)
|
||||
const extensionMode=location.pathname==='/extension',overlayParams=new window.URLSearchParams(location.search),localOverlayHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname),overlayDevMode=extensionMode&&localOverlayHost&&overlayParams.get('dev')==='1';document.documentElement.classList.toggle('extension-mode',extensionMode);document.documentElement.classList.toggle('overlay-debug',extensionMode&&localOverlayHost&&overlayParams.get('debug')==='1');document.documentElement.classList.toggle('overlay-dev',overlayDevMode)
|
||||
const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s)
|
||||
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
||||
function disabledReason(){if(extensionIdentityPending)return 'Share your Twitch identity to enable controls.';if(!userId)return extensionMode?'Waiting for Twitch authorization.':'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return extensionMode?'Click Spawn character to join.':'Type !spawn in chat first.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''}
|
||||
@@ -9,7 +9,7 @@ function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorN
|
||||
const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML=''
|
||||
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const p=state.players.find(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y);const gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob||p){const e=document.createElement('span');e.className=`entity ${gob?'goblin':'player'}`;e.textContent=gob?'◆':'●';e.title=gob?'Goblin':p.displayName;el.append(e)}map.append(el)}
|
||||
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>`<li><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`).join('');$('#log').scrollTop=$('#log').scrollHeight
|
||||
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character'
|
||||
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character'
|
||||
}
|
||||
async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error||data.reason||'Request failed');return data}
|
||||
async function authorizeExtension(token){const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token})});session=auth.token;userId=auth.twitchUserId;state=auth.state;if(activeSocket?.readyState===WebSocket.OPEN)activeSocket.send(JSON.stringify({type:'authenticate',token:session}));render()}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"><style>body.extension-mode{background:transparent}body.extension-mode .shell{height:100vh;min-height:0;padding:0;display:block}body.extension-mode .status{height:100%;padding:16px;gap:14px;background:#111315ee}body.extension-mode .game,body.extension-mode .log,body.extension-mode details{display:none}body.extension-mode h1{font-size:22px}body.extension-mode #status{grid-template-columns:repeat(3,1fr)}</style></head>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"></head>
|
||||
<body><main class="shell">
|
||||
<aside class="panel status"><div><p class="eyebrow">Twitch plays together</p><h1>TWUNG<span>EON</span></h1></div><div id="status"></div>
|
||||
<section id="controller"><h2>Controller</h2><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up">▲</button><button data-command="left">◀</button><button data-command="down">▼</button><button data-command="right">▶</button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled"></p></section>
|
||||
<aside class="panel status" id="controlRegion"><div class="branding"><p class="eyebrow">Twitch plays together</p><h1>TWUNG<span>EON</span></h1></div><div id="status"></div>
|
||||
<section id="controller"><h2>Controller</h2><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up" aria-label="Move up">▲</button><button data-command="left" aria-label="Move left">◀</button><button data-command="down" aria-label="Move down">▼</button><button data-command="right" aria-label="Move right">▶</button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled" role="status"></p></section>
|
||||
<details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button><p><a href="/privacy.html" target="_blank" rel="noopener">Privacy notice</a></p></details>
|
||||
</aside>
|
||||
<section class="panel game"><div class="game-head"><div><p class="eyebrow">Shared dungeon</p><h2 id="floor">Floor 1</h2></div><div id="phase" class="phase"></div></div><div id="banner" hidden>Type !spawn to spawn in the Twungeon!</div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span>● Adventurer</span><span>◆ Goblin</span><span>▣ Exit</span></div></section>
|
||||
<section class="panel log"><div class="log-head"><div><p class="eyebrow">Chronicle</p><h2>Action log</h2></div><span id="connection">Connecting…</span></div><ol id="log"></ol></section>
|
||||
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-spawn"></script></body></html>
|
||||
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-overlay"></script></body></html>
|
||||
|
||||
@@ -1 +1,112 @@
|
||||
:root{color-scheme:dark;--ink:#f5ecd8;--muted:#b8ad98;--gold:#e8b04b;--red:#db604c;--panel:#171a1d;--line:#34383b;--floor:#3a3832;--wall:#111315}*{box-sizing:border-box}body{margin:0;background:#0d0f10;color:var(--ink);font:15px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;background-image:radial-gradient(#252017 1px,transparent 1px);background-size:20px 20px}.shell{height:100vh;min-height:650px;padding:18px;display:grid;grid-template-columns:minmax(260px,28%) 1fr;grid-template-rows:minmax(0,1fr) 31%;gap:14px}.panel{background:linear-gradient(145deg,#191c1f,#111315);border:1px solid var(--line);box-shadow:0 18px 60px #0008,inset 0 1px #ffffff0d;border-radius:5px}.status{padding:22px;display:flex;flex-direction:column;gap:20px;overflow:auto}.game{padding:20px;display:flex;flex-direction:column;min-width:0;position:relative}.log{grid-column:1/-1;padding:15px 20px;overflow:hidden;display:flex;flex-direction:column}h1,h2,p{margin:0}h1{font:800 31px/1 system-ui;letter-spacing:.08em}h1 span{color:var(--gold)}h2{font:700 18px/1.2 system-ui}.eyebrow{text-transform:uppercase;color:var(--gold);font-size:11px;letter-spacing:.16em;margin-bottom:6px}.game-head,.log-head{display:flex;align-items:center;justify-content:space-between}.phase{padding:6px 10px;border:1px solid var(--gold);color:var(--gold);border-radius:2px;text-transform:uppercase;font-size:12px}#banner{position:absolute;z-index:3;inset:45% auto auto 50%;transform:translate(-50%,-50%);padding:16px 22px;background:#111e;border:1px solid var(--gold);box-shadow:0 0 40px #000;text-align:center;color:var(--gold);font-weight:bold;white-space:nowrap}#map{flex:1;display:grid;align-content:center;justify-content:center;margin:12px 0;min-height:0}.tile{width:min(3.6vw,42px);aspect-ratio:1;border:1px solid #222;display:grid;place-items:center;position:relative;font-size:min(1.6vw,18px)}.tile.wall{background:var(--wall);box-shadow:inset 0 0 0 2px #1b1f21}.tile.floor{background:var(--floor)}.tile.exit{background:#68491b;color:#ffd77e}.entity{position:absolute;inset:12%;border-radius:50%;display:grid;place-items:center;font-weight:bold;text-shadow:0 1px 2px #000}.entity.player{background:#4f8cc9;border:2px solid #b9deff}.entity.goblin{background:var(--red);border:2px solid #ffb2a6;border-radius:20%}.legend{display:flex;gap:18px;justify-content:center;color:var(--muted);font-size:12px}#status{display:grid;grid-template-columns:1fr 1fr;gap:8px}.stat{border:1px solid var(--line);padding:9px}.stat b{display:block;color:var(--gold);font-size:18px}.dpad{display:grid;grid-template-columns:repeat(3,42px);gap:5px;margin:10px 0}.dpad button:nth-child(1){grid-column:2}.dpad button:nth-child(2){grid-column:1}.actions{display:flex;gap:5px;flex-wrap:wrap}button,input{font:inherit}button{background:#292d2f;color:var(--ink);border:1px solid #53595c;padding:8px 10px;cursor:pointer}button:hover:not(:disabled){border-color:var(--gold);color:var(--gold)}button:disabled{opacity:.35;cursor:not-allowed}#disabled{color:var(--muted);font-size:12px;margin-top:8px}details{margin-top:auto;color:var(--muted)}details label{display:block;margin:8px 0}input{display:block;width:100%;background:#0d0f10;border:1px solid var(--line);color:var(--ink);padding:6px}#log{margin:8px 0 0;padding:0;list-style:none;overflow:auto;display:flex;flex-direction:column;gap:3px}#log li{padding:4px 8px;border-left:2px solid var(--line);color:var(--muted)}#log li:last-child{color:var(--ink);border-color:var(--gold)}#connection{font-size:12px;color:var(--muted)}@media(max-width:760px){.shell{height:auto;grid-template-columns:1fr;grid-template-rows:auto minmax(500px,70vh) 360px}.log{grid-column:1}.tile{width:min(5vw,28px)}#banner{white-space:normal;width:75%}}
|
||||
:root {
|
||||
--ink: #f5ecd8;
|
||||
--muted: #b8ad98;
|
||||
--gold: #e8b04b;
|
||||
--red: #db604c;
|
||||
--panel: #171a1d;
|
||||
--line: #34383b;
|
||||
--floor: #3a3832;
|
||||
--wall: #111315;
|
||||
|
||||
/* Normalized to the matching control panel in the 16:9 broadcast layout. */
|
||||
--controls-left: 0.95%;
|
||||
--controls-top: 1.7%;
|
||||
--controls-width: 27.1%;
|
||||
--controls-height: 65.8%;
|
||||
--controls-padding: clamp(8px, 1.15vw, 22px);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html:not(.extension-mode) { color-scheme: dark; }
|
||||
html.extension-mode { color-scheme: normal; }
|
||||
body { margin: 0; background: #0d0f10; color: var(--ink); font: 15px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; background-image: radial-gradient(#252017 1px, transparent 1px); background-size: 20px 20px; }
|
||||
.shell { height: 100vh; min-height: 650px; padding: 18px; display: grid; grid-template-columns: minmax(260px, 28%) 1fr; grid-template-rows: minmax(0, 1fr) 31%; gap: 14px; }
|
||||
.panel { background: linear-gradient(145deg, #191c1f, #111315); border: 1px solid var(--line); box-shadow: 0 18px 60px #0008, inset 0 1px #ffffff0d; border-radius: 5px; }
|
||||
.status { padding: 22px; display: flex; flex-direction: column; gap: 20px; overflow: auto; }
|
||||
.game { padding: 20px; display: flex; flex-direction: column; min-width: 0; position: relative; }
|
||||
.log { grid-column: 1/-1; padding: 15px 20px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font: 800 31px/1 system-ui; letter-spacing: .08em; }
|
||||
h1 span { color: var(--gold); }
|
||||
h2 { font: 700 18px/1.2 system-ui; }
|
||||
.eyebrow { text-transform: uppercase; color: var(--gold); font-size: 11px; letter-spacing: .16em; margin-bottom: 6px; }
|
||||
.game-head, .log-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.phase { padding: 6px 10px; border: 1px solid var(--gold); color: var(--gold); border-radius: 2px; text-transform: uppercase; font-size: 12px; }
|
||||
#banner { position: absolute; z-index: 3; inset: 45% auto auto 50%; transform: translate(-50%, -50%); padding: 16px 22px; background: #111e; border: 1px solid var(--gold); box-shadow: 0 0 40px #000; text-align: center; color: var(--gold); font-weight: bold; white-space: nowrap; }
|
||||
#map { flex: 1; display: grid; align-content: center; justify-content: center; margin: 12px 0; min-height: 0; }
|
||||
.tile { width: min(3.6vw, 42px); aspect-ratio: 1; border: 1px solid #222; display: grid; place-items: center; position: relative; font-size: min(1.6vw, 18px); }
|
||||
.tile.wall { background: var(--wall); box-shadow: inset 0 0 0 2px #1b1f21; }
|
||||
.tile.floor { background: var(--floor); }
|
||||
.tile.exit { background: #68491b; color: #ffd77e; }
|
||||
.entity { position: absolute; inset: 12%; border-radius: 50%; display: grid; place-items: center; font-weight: bold; text-shadow: 0 1px 2px #000; }
|
||||
.entity.player { background: #4f8cc9; border: 2px solid #b9deff; }
|
||||
.entity.goblin { background: var(--red); border: 2px solid #ffb2a6; border-radius: 20%; }
|
||||
.legend { display: flex; gap: 18px; justify-content: center; color: var(--muted); font-size: 12px; }
|
||||
#status { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.stat { border: 1px solid var(--line); padding: 9px; }
|
||||
.stat b { display: block; color: var(--gold); font-size: 18px; }
|
||||
.dpad { display: grid; grid-template-columns: repeat(3, 42px); gap: 5px; margin: 10px 0; }
|
||||
.dpad button:nth-child(1) { grid-column: 2; }
|
||||
.dpad button:nth-child(2) { grid-column: 1; }
|
||||
.actions { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
button, input { font: inherit; }
|
||||
button { background: #292d2f; color: var(--ink); border: 1px solid #53595c; padding: 8px 10px; cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--gold); color: var(--gold); }
|
||||
button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
||||
button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
#disabled { color: var(--muted); font-size: 12px; margin-top: 8px; }
|
||||
details { margin-top: auto; color: var(--muted); }
|
||||
details label { display: block; margin: 8px 0; }
|
||||
input { display: block; width: 100%; background: #0d0f10; border: 1px solid var(--line); color: var(--ink); padding: 6px; }
|
||||
#log { margin: 8px 0 0; padding: 0; list-style: none; overflow: auto; display: flex; flex-direction: column; gap: 3px; }
|
||||
#log li { padding: 4px 8px; border-left: 2px solid var(--line); color: var(--muted); }
|
||||
#log li:last-child { color: var(--ink); border-color: var(--gold); }
|
||||
#connection { font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* Twitch Video Overlay mode: the stream supplies every noninteractive surface. */
|
||||
html.extension-mode, html.extension-mode body { width: 100%; height: 100%; overflow: hidden; background: transparent; background-image: none; pointer-events: none; }
|
||||
html.extension-mode .shell { position: fixed; inset: 0; display: block; min-height: 0; padding: 0; pointer-events: none; }
|
||||
html.extension-mode .game, html.extension-mode .log, html.extension-mode .branding, html.extension-mode #status, html.extension-mode details { display: none; }
|
||||
html.extension-mode .status {
|
||||
position: absolute;
|
||||
left: var(--controls-left);
|
||||
top: var(--controls-top);
|
||||
width: var(--controls-width);
|
||||
height: var(--controls-height);
|
||||
padding: var(--controls-padding);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
container: overlay-controls / size;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
html.extension-mode #controller { width: min(100%, 18rem); pointer-events: none; }
|
||||
html.extension-mode #controller h2 { font-size: clamp(11px, 5cqw, 18px); margin-bottom: clamp(4px, 2cqh, 12px); text-shadow: 0 1px 3px #000; }
|
||||
html.extension-mode #controller button { pointer-events: auto; touch-action: manipulation; }
|
||||
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(28px, 1fr)); gap: clamp(3px, 1.8cqw, 7px); margin: 0 0 clamp(6px, 2.5cqh, 14px); }
|
||||
html.extension-mode .dpad button { aspect-ratio: 1; padding: 0; font-size: clamp(13px, 6cqw, 24px); }
|
||||
html.extension-mode .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(3px, 1.8cqw, 7px); }
|
||||
html.extension-mode .actions button { padding: clamp(5px, 1.7cqh, 10px) clamp(2px, 1cqw, 8px); font-size: clamp(9px, 4.3cqw, 15px); }
|
||||
html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { width: 100%; margin-bottom: clamp(5px, 2cqh, 12px); font-size: clamp(9px, 4.3cqw, 15px); }
|
||||
html.extension-mode #disabled { min-height: 2.8em; margin-top: clamp(5px, 2cqh, 10px); color: var(--ink); font-size: clamp(9px, 3.7cqw, 13px); text-shadow: 0 1px 3px #000, 0 0 8px #000; }
|
||||
|
||||
/* Opt-in local diagnostics. These classes are never enabled by production URLs. */
|
||||
html.extension-mode.overlay-debug .status { outline: 2px dashed #00e5ff; outline-offset: -2px; background: #00e5ff0d; }
|
||||
html.extension-mode.overlay-debug #controller { outline: 1px dashed #ff4fd8; }
|
||||
html.extension-mode.overlay-dev details { display: block; width: 100%; margin-top: clamp(8px, 3cqh, 16px); pointer-events: auto; font-size: clamp(9px, 3.7cqw, 13px); }
|
||||
html.extension-mode.overlay-dev .status { place-items: start center; overflow: auto; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
html:not(.extension-mode) .shell { height: auto; grid-template-columns: 1fr; grid-template-rows: auto minmax(500px, 70vh) 360px; }
|
||||
html:not(.extension-mode) .log { grid-column: 1; }
|
||||
html:not(.extension-mode) .tile { width: min(5vw, 28px); }
|
||||
html:not(.extension-mode) #banner { white-space: normal; width: 75%; }
|
||||
}
|
||||
|
||||
@media (max-aspect-ratio: 4/3) {
|
||||
html.extension-mode { --controls-width: 31%; --controls-height: 62%; }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,37 @@ Twitch identities; they do not require credentials.
|
||||
The local-only endpoints `/api/dev/spawn` and `/api/dev/redemption` exist only while
|
||||
`TWITCH_ENABLED` is false.
|
||||
|
||||
## Local video-overlay verification
|
||||
|
||||
The production viewer path is `/extension`. It is safe to open directly, but it
|
||||
waits for Twitch authorization when it is not inside Twitch. While running the
|
||||
synthetic local backend, open:
|
||||
|
||||
```text
|
||||
http://localhost:3000/extension?dev=1&debug=1
|
||||
```
|
||||
|
||||
`dev=1` exposes the existing **Local viewer login** inside the overlay so the
|
||||
same controller and API flow can be exercised without Twitch. `debug=1` draws a
|
||||
cyan control-region boundary and a pink controller boundary. Both switches are
|
||||
accepted only on `localhost`, `127.0.0.1`, or `[::1]`, so the diagnostics cannot
|
||||
be enabled on the deployed Extension origin.
|
||||
|
||||
Verify at 1920×1080, 1280×720, and a narrower or 4:3 viewport:
|
||||
|
||||
- the document and empty overlay canvas remain transparent;
|
||||
- controls stay within the upper-left debug boundary and remain readable;
|
||||
- the dungeon, log, branding, and status tiles are absent from `/extension`;
|
||||
- only buttons (and the local-only login form) accept pointer events;
|
||||
- Up, Down, Left, Right, Attack, Heal, and Pass reach the same backend routes;
|
||||
- missing character, dead character, non-player phase, and zero-AP states still
|
||||
disable the controls; and
|
||||
- loading `/` still presents the full local game and controller.
|
||||
|
||||
Remove the query string for the production-shaped local view. Twitch-hosted
|
||||
testing still must cover identity sharing, player ownership, theater mode,
|
||||
fullscreen, embeds, ads/pauses, and Twitch player-control safe zones.
|
||||
|
||||
## Live-channel campaign
|
||||
|
||||
After completing `docs/twitch-setup.md`, use two follower accounts and one
|
||||
|
||||
+26
-7
@@ -70,18 +70,37 @@ token file inside the repository or a web-served directory.
|
||||
|
||||
## Extension configuration
|
||||
|
||||
Twungeon's production viewer is a **Video Overlay Extension**. Twitch's current
|
||||
Extension Manager labels this asset type **Video - Fullscreen** and its path
|
||||
field **Video - Fullscreen View Path**. This is a manual dashboard setting; the
|
||||
repository has no manifest that can change an Extension version in Twitch.
|
||||
|
||||
1. Create a Twitch Extension separately from the confidential OAuth
|
||||
application. In the Extension Manager, use a testing base URI ending in
|
||||
`/`, select a video component, and set its viewer path to `extension`.
|
||||
2. Enable **Request Identity Link**. The viewer must click **Share Twitch
|
||||
identity** in the component; the Extension Helper then invokes
|
||||
application, or create a new test version of the existing Twungeon
|
||||
Extension.
|
||||
2. On **Asset Hosting**, set **Testing Base URI** to the public HTTPS Twungeon
|
||||
origin with a trailing `/`.
|
||||
3. Under **Type of Extension**, enable **Video - Fullscreen** (the video-overlay
|
||||
placement) and set **Video - Fullscreen View Path** to `extension`. Do not
|
||||
use **Video - Component** for the production viewer. If a component entry is
|
||||
retained temporarily for development, it may point at the same shared page,
|
||||
but the overlay placement is the supported viewer experience.
|
||||
4. On **Capabilities**, enable **Request Identity Link**. The viewer must click
|
||||
**Share Twitch identity** in the overlay; the Extension Helper then invokes
|
||||
`requestIdShare()` from that user gesture and supplies a new JWT through
|
||||
`onAuthorized`.
|
||||
3. A JWT without `user_id`, with the wrong
|
||||
5. Put the version in Local Test or Hosted Test and activate it in the channel's
|
||||
video-overlay slot. Changing an already released version may require a new
|
||||
Extension version and Twitch review; the repository cannot submit or
|
||||
activate that version automatically.
|
||||
6. Test the overlay while live in normal, theater, fullscreen, and a narrow
|
||||
player. The video player and stream supply the game frame; `/extension`
|
||||
should show only the upper-left controls on a transparent canvas.
|
||||
7. A JWT without `user_id`, with the wrong
|
||||
`channel_id`, an expired signature, or an `external` role is rejected.
|
||||
4. Keep the Extension secret only in the backend environment. It must never be
|
||||
8. Keep the Extension secret only in the backend environment. It must never be
|
||||
included in the UI bundle or URL.
|
||||
5. Start with `npm run build && npm start`. Confirm `/health` returns `ready:
|
||||
9. Start with `npm run build && npm start`. Confirm `/health` returns `ready:
|
||||
true` and `twitchMode: "configured"` after chat connects.
|
||||
|
||||
The Extension JWT is exchanged for a random 15-minute Twungeon session. A
|
||||
|
||||
@@ -9,7 +9,7 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas
|
||||
describe('backend boundary',()=>{
|
||||
it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)})
|
||||
it('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)})
|
||||
it('serves a compact uncached Twitch identity-sharing controller',async()=>{const [pageResponse,scriptResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/app.js?v=20260817-spawn`)]),[html,script]=await Promise.all([pageResponse.text(),scriptResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('/app.js?v=20260817-spawn');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(html).toContain('body.extension-mode .game');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain('/api/extension/spawn');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()')})
|
||||
it('serves a transparent responsive Twitch overlay controller',async()=>{const [pageResponse,rootResponse,scriptResponse,styleResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/`),fetch(`${base}/app.js?v=20260817-overlay`),fetch(`${base}/styles.css`)]),[html,rootHtml,script,styles]=await Promise.all([pageResponse.text(),rootResponse.text(),scriptResponse.text(),styleResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(styleResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('<html lang="en" class="extension-mode">');expect(rootHtml).not.toContain('<html lang="en" class="extension-mode">');expect(html).toContain('/app.js?v=20260817-overlay');expect(html).toContain('id="controlRegion"');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain("['localhost','127.0.0.1','[::1]']");expect(script).toContain('/api/extension/spawn');expect(script).toContain('/api/commands');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()');expect(styles).toContain('--controls-left:');expect(styles).toContain('html.extension-mode { color-scheme: normal; }');expect(styles).toContain('background: transparent');expect(styles).toContain('pointer-events: none');expect(styles).toContain('#controller button { pointer-events: auto')})
|
||||
it('serves a public privacy notice for identity linking',async()=>{const response=await fetch(`${base}/privacy.html`),html=await response.text();expect(response.status).toBe(200);expect(html).toContain('Twungeon Privacy Notice');expect(html).toContain('numeric Twitch user ID')})
|
||||
it('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()})
|
||||
it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)})
|
||||
|
||||
Reference in New Issue
Block a user