Convert Twitch controls to video overlay

This commit is contained in:
2026-08-17 13:49:39 -07:00
parent 9ebd1ea283
commit a8a86852c0
8 changed files with 182 additions and 16 deletions
+4
View File
@@ -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:
+2 -1
View File
@@ -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)}
+2 -2
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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()}
+4 -4
View File
@@ -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>
+112 -1
View File
@@ -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%; }
}
+31
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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)})